Lifecycle Events
The customer-level lifecycle webhook delivers every build and revision state change to a single URL you configure once per account.
Configuration
Set up the lifecycle webhook with PUT /v1/customer/webhook:
curl -X PUT https://api.warpweb.ai/v1/customer/webhook \
-H "Authorization: Bearer $WARPWEB_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.yourapp.com/webhooks/warpweb-lifecycle"
}'First-time setup auto-generates the signing secret and returns the plaintext once in secret_plaintext — capture it immediately. To rotate later: POST /v1/customer/webhook/regenerate-secret.
By default you’ll receive all lifecycle event types. To narrow that down:
curl -X PATCH https://api.warpweb.ai/v1/customer/webhook/subscriptions \
-H "Authorization: Bearer $WARPWEB_KEY" \
-H "Content-Type: application/json" \
-d '{
"events": ["site.complete", "site.failed", "site.revision_complete", "site.revision_failed"]
}'To exercise your receiver before real traffic, fire a synthetic event:
curl -X POST https://api.warpweb.ai/v1/customer/webhook/test-ping \
-H "Authorization: Bearer $WARPWEB_KEY" \
-H "Content-Type: application/json" \
-d '{ "event_type": "site.complete" }'The test ping uses the canonical sample payload for that event type, with fresh event_id + occurred_at. Omit event_type to send the default webhook.test_ping envelope.
Response shape:
{
"ok": true,
"status": 200,
"response_body_preview": "ok",
"duration_ms": 142,
"event_type": "site.complete"
}response_body_preview is truncated to 500 chars; duration_ms is wall-clock to first byte. 5-second timeout. No retries — a transient failure shows up here as ok: false and you decide whether to call again.
Event types
type | When it fires |
|---|---|
site.research_ready | Research is done. In auto mode (default), generation begins immediately; in manual mode this is your cue to fetch /research and POST /approve-research. |
site.complete | An initial POST /v1/sites build deployed successfully. |
site.failed | An initial build failed. No credits charged. |
site.revision_complete | A POST /v1/sites/:id/revisions revision deployed successfully. |
site.revision_failed | A revision failed (or was ruled out of scope). No credits charged. |
site.revision_clarification_needed | The agent needs more info before proceeding. |
form.submit | Form submission on a deployed site (same payload as the per-site form webhook). |
webhook.test_ping | Synthetic — only fired by the test-ping endpoints. Not subscribable; receivers should accept-and-no-op. |
Envelope
Every lifecycle webhook carries this envelope:
{
"event_id": "00000000-0000-0000-0000-000000000001",
"type": "site.complete",
"site_id": "11111111-1111-1111-1111-111111111111",
"occurred_at": "2026-05-18T12:05:00.000Z",
"payload": {
"...": "type-specific fields"
}
}Headers match Form Submissions — X-Warpweb-Event-Id, X-Warpweb-Event-Type, X-Warpweb-Timestamp, X-Warpweb-Signature, User-Agent: Warpweb-Webhook/2.0.
Payload shapes
Listed in roughly the order a single site fires them. An initial build emits site.research_ready mid-build, then either site.complete or site.failed. Each subsequent POST /v1/sites/:id/revisions emits exactly one of site.revision_complete, site.revision_failed, or site.revision_clarification_needed.
site.research_ready
Fires after research completes — before generation runs. What you do with it depends on the review mode you passed at create time and on whether the email cascade landed an address:
- Auto mode (default, or
"review": "auto"onPOST /v1/sites) with an email resolved: generation has already started. Treat this as a progress heartbeat and wait forsite.complete. - Auto mode with
email_status: "missing": the email cascade returned null and the safety net paused the site. Render an “add an email” affordance to the operator and POST it on/v1/sites/:id/approve-researchascontactEmailto advance. (To opt out of this pause and accept a no-email site, passallowMissingEmail: trueat create time — see Email resolution.) - Auto mode with
email_status: "placeholder": the cascade landed on the operator’s own auth email — the build still auto-advances, but a soft nudge (“we’re using your inbox, want the customer’s?”) is appropriate. The same nudge fires again onsite.complete. - Manual mode (
"review": "manual"on create): the site is paused ingeneration_phase: "research_review". GET/v1/sites/:id/research, inspect / edit the payload, then POST/v1/sites/:id/approve-researchto start the build.
{
"payload": {
"research_url": "https://app.warpweb.ai/sites/<site_id>/research",
"business_name": "Brookside Plumbing",
"business_category": "plumbing_services",
"email_status": "confirmed",
"siteforge_user_id": "..."
}
}email_status is the three-state contact-email signal — missing, placeholder, or confirmed. See Email resolution for the full semantics and branch recommendations.
site.complete
{
"event_id": "...",
"type": "site.complete",
"site_id": "...",
"occurred_at": "...",
"payload": {
"site_id": "...",
"deployment_url": "https://brookside-plumbing-a1b2c3.warpweb.app",
"slug": "brookside-plumbing-a1b2c3",
"business_name": "Brookside Plumbing",
"siteforge_user_id": "...",
"status": "complete",
"email_status": "confirmed",
"input": {
"mapsUrl": "https://maps.app.goo.gl/AbCdEfGh",
"businessName": "Brookside Plumbing",
"received_at": "2026-05-26T18:42:09.221Z"
},
"usage": {
"cost_usd": 1.42,
"tokens_in": 85421,
"tokens_out": 12804
}
}
}The usage.cost_usd field is the actual AI spend on this build — surface it in your billing dashboard or bill your end-customer accordingly.
email_status carries the live contact-email signal at completion time — missing, placeholder, or confirmed. Use it to fire post-build nudges (“your site is live, but it’s still using your email — want the customer’s?”). See Email resolution.
The input object is the build-input audit trail: a snapshot of what the caller originally passed to POST /v1/sites. Shape: { mapsUrl?, placeId?, businessName?, location?, facebookUrl?, existingSiteUrl?, received_at }. Only non-empty fields appear; received_at is always present. Useful when a build came out wrong (Places returned the wrong duplicate profile for a business with two listings, etc.) — surface “Built from <url>” in your end-customer UI so they can retry with a different URL. Same field is also returned by GET /v1/sites/:id.
Ship-with-warnings variant
When the visual-review rubric flagged issues that progressive-steering attempts could not clear, the same site.complete event fires with status: "complete_with_warnings" and a warnings[] array. The site is live (deployment_url works); the warnings are advisory and operators can iterate via POST /v1/sites/:id/revisions using the suggested prompts.
{
"payload": {
"site_id": "...",
"deployment_url": "https://brookside-plumbing-a1b2c3.warpweb.app",
"slug": "brookside-plumbing-a1b2c3",
"business_name": "Brookside Plumbing",
"status": "complete_with_warnings",
"warnings": [
{
"rule": "hero-is-wedge-not-list",
"severity": "error",
"page": "index.html",
"viewport": "desktop",
"description": "Hero shows 6 service tiles instead of one focused wedge.",
"remediation_prompt": "Hero must show ONE focused problem statement above the fold. NO service grids, NO icon-and-label tiles, NO badge rows of certifications. The hero is a wedge, not a list — move the service grid below the hero."
}
],
"siteforge_user_id": "...",
"usage": { "cost_usd": 1.42, "tokens_in": 85421, "tokens_out": 12804 }
}
}Receivers should treat complete and complete_with_warnings as equivalent for the “site is deployed” branch — both fire the same site.complete event with a live deployment_url. Billing is identical for both: the generator did the work and the customer has a usable site. See GET /v1/sites/:id → Ship-with-warnings for the architectural rationale.
site.failed
{
"payload": {
"site_id": "...",
"failed_phase": "generation",
"failed_reason": "sdk_error",
"last_error": "Upstream model returned 529 overloaded after retries",
"siteforge_user_id": "..."
}
}failed_phase | Meaning |
|---|---|
research | Failed during research. |
generation | Failed during content/design generation. |
review | Failed during the pre-deploy review step. |
deploy | Failed during deploy. |
last_error is a human-readable string suitable for surfacing in your UI. failed_reason is a coarse machine-readable category — your code should branch on failed_phase for retry decisions, not on failed_reason.
site.revision_complete
{
"payload": {
"site_id": "...",
"revision_id": "...",
"deployment_url": "https://brookside-plumbing-a1b2c3.warpweb.app",
"summary": "Updated hero copy and added a Services section with 3 service cards.",
"iterations_used": 3,
"siteforge_user_id": "...",
"usage": {
"cost_usd": 0.41,
"tokens_in": 24113,
"tokens_out": 4902
}
}
}summary is a short human-readable description of what the agent actually changed — useful to surface in chat-style UIs as “Here’s what I did:”.
site.revision_failed
{
"payload": {
"site_id": "...",
"revision_id": "...",
"reason": "out_of_scope",
"reason_detail": "Requires server-side execution at request time. Deployed sites are static HTML on a CDN.",
"last_error": "Requires server-side execution at request time. Deployed sites are static HTML on a CDN.",
"suggestion": "I can add a 'Contact us' form that posts to your form_webhook_url — you handle the dispatch on your end.",
"failed_at": "2026-05-18T13:10:00.000Z",
"siteforge_user_id": "..."
}
}reason is a machine-readable code; reason_detail carries the human-readable explanation (also mirrored into last_error for convenience if you log it). Branch your code on reason; show users reason_detail.
reason | Meaning |
|---|---|
out_of_scope | The request requires server-side functionality Warpweb doesn’t host. suggestion carries the customer-facing alternative — typically a form-webhook-based pattern. Surface suggestion in your UI. |
typecheck_iteration_cap | The agent couldn’t reach a clean typecheck within the iteration cap. Retry rarely helps — the prompt is likely asking for something that conflicts with the generated codebase. |
sdk_error | Upstream model/SDK error (e.g. 529 overloaded after retries). Safe to retry. |
queue_timeout | Revision sat in the queue longer than the operational SLO. Safe to retry. |
infrastructure | Transient infrastructure error. Safe to retry the revision. |
cancelled | Operator cancelled the revision in-flight. |
| Other strings | Free-form. For machine-readable branching prefer failed_phase (a stable enum); reason may carry one-off strings introduced by new revision-loop phases. |
For out_of_scope and other client-actionable failures, surface the suggestion field directly — it’s written for the end user.
site.revision_clarification_needed
{
"payload": {
"site_id": "...",
"revision_id": "...",
"question": "You asked me to update the phone number, but the site has three different numbers (header, contact form, footer). Which should I change?",
"context": "Header: (555) 010-1111 — Contact: (555) 010-2222 — Footer: (555) 010-3333",
"siteforge_user_id": "..."
}
}Resolve by sending a new POST /v1/sites/:id/revisions call with the clarifying answer included in the prompt.
form.submit
Same payload as Form Submissions. Useful if you’d rather receive form submissions on the same URL as your lifecycle events. To enable, include form.submit in your subscription list. To disable, omit it — the per-site form webhook still works independently.
Retry + idempotency
Same contract as form submissions: 2xx done, 4xx not retried, 5xx/network → immediate / +30s / +5min / +30min / dead-letter. Use event_id as the dedupe key.
Why use this over polling
- Faster. Events fire within seconds of the underlying state change; polling adds latency proportional to your poll interval.
- Cheaper. No rate-limit budget spent on poll churn.
- Correct. Avoids the race where polling could miss a transient state. The event is the source of truth.
Polling stays supported. Use it for one-off scripts; use webhooks for production.