# Warpweb — Full API Reference for LLMs > REST API for AI-generated websites. This file inlines every public V1 > endpoint with request/response shapes and webhook events so an LLM > assistant can answer "how do I do X with Warpweb" without fetching > additional pages. Base URL: `https://api.warpweb.ai/v1` Auth: `Authorization: Bearer wwk_...` (issued at https://warpweb.ai/app) Content type: `application/json` for all request bodies Async model: long-running operations return immediately with a resource id; the final result is delivered via a customer-level lifecycle webhook. --- ## 0. What the pipeline does Given a business name + email (the only required inputs), the `POST /v1/sites` pipeline: 1. **Researches** — pulls Google Places (hours, address, phone, photos, **reviews**, **service area**, category), scans competitors, infers vertical 2. **Drafts content** — copy in a voice matched to the vertical (septic-services ≠ med-spa), not generic filler 3. **Selects photos** — Places photos for the business → vertical-appropriate stock → customer-uploaded photos (if any) 4. **Generates design** — vertical-aware template + colors/typography tuned to brand 5. **Builds multi-page structure** — home, about, services, contact (plus service-area maps, review widgets, FAQ blocks where they earn their keep) 6. **Adds SEO** — per-page meta tags, sitemap.xml, Schema.org JSON-LD (LocalBusiness w/ service area, OpeningHours, AggregateRating when reviews exist) 7. **Deploys** — global edge CDN with SSL auto-provisioned 8. **Wires forms** — every form on the deployed site posts to your configured webhook URL with HMAC signature Revisions (`POST /v1/sites/:id/revisions`) are natural-language: "make the hero darker", "add an FAQ about emergency repairs", "move service area below testimonials". The agent picks a fast targeted patch for small edits and a full rewrite path for restructures — chosen automatically based on scope. Sites can be iterated conversationally indefinitely. --- ## 0.5. Business disambiguation (recommended before POST /v1/sites) ### POST /v1/businesses/search — find the right Google Places match When the caller has only a business name (especially a common one like "Acme Plumbing"), calling POST /v1/sites directly is risky — the engine auto-matches against Google Places and uses the FIRST result, which may be the wrong business. Use this endpoint first to pick a specific placeId, then pass it to /v1/sites. Request body: ``` { "query": "Acme Plumbing", // required, string "location": "Austin, TX" // optional, narrows the search } ``` Response (200): ``` { "results": [ { "placeId": "ChIJ_example_acme_plumbing_austin_tx", "name": "Acme Plumbing & Drain", "address": "123 Main St, Austin, TX 78701, USA", "phone": "+1 512-555-0123", "rating": 4.7, "userRatingCount": 184, "website": "https://acmeplumbing.example.com", "googleMapsUri": "https://maps.google.com/?cid=12345", "businessType": "plumber" } // up to 5 candidates total, ordered by Places ranking ] } ``` Errors: - 400 `{ "error": "Request body must be valid JSON" }` - 400 `{ "error": "\"query\" is required in the request body" }` Credit cost: 0 (free, not metered). Two-step flow (strongly recommended for LLM-agent integrations): 1. POST /v1/businesses/search { query, location? } → pick the right placeId 2. POST /v1/sites { businessName, contactEmail, placeId } → exact match `POST /v1/sites` accepts a `placeId` field that, when present, short-circuits the auto-match: research data comes from that exact Place, no ambiguity. --- ## 1. Sites ### POST /v1/sites — create a site (async) Kicks off the research → build → deploy pipeline in the background. Returns immediately with the new site id. Final result arrives via the `site.complete` (or `site.failed`) lifecycle webhook. Request body: ``` { "businessName": "Acme Plumbing", // required, string, 1–200 chars "contactEmail": "owner@acme.com", // required, string, valid email "placeId": "ChIJN1t_...", // recommended for common names — // from POST /v1/businesses/search; // when set, research pulls from this // exact Google Place (no ambiguity) "businessLocation": "Austin, TX", // recommended when placeId not set — // narrows the auto-Places-match "businessDescription": "...", // optional, free text "ownerPrompt": "Emphasize 24/7 service", // optional, free text "facebookUrl": "https://facebook.com/…", // optional "uploadedPhotos": [{ "url": "...", ... }],// optional, array "designStyle": "modern" // optional, see GET /api/styles } ``` Response (200): ``` { "siteId": "uuid", "status": "generating", "slug": "acme-plumbing-a1b2c3" } ``` Errors: - 400 `{ "error": "businessName is required" }` or `"contactEmail is required"` - 402 PAYMENT_REQUIRED — balance is 0 and auto-refill is off - 429 — daily site-generation quota exceeded; includes `quota` object - 500 `{ "error": "Failed to create site record" }` Webhook(s) that fire after this call: - `site.research_ready` — research phase done, build queued - `site.complete` — deployment succeeded (final preview URL) - `site.failed` — pipeline failed at any phase (includes failed_phase + last_error) Credit cost: ~200–500 credits, billed at end-of-build against actual AI usage (no flat charge at call time). --- ### GET /v1/sites — list sites Lists all sites for the calling customer. Free and idempotent — also useful as a key-verification smoke test before any credit-spending call: a freshly created account returns `{ "sites": [] }`; an invalid key returns 401. Response (200): ``` [ { "id": "uuid", "slug": "...", "business_name": "...", "status": "complete" | "generating" | "failed" | "research_review", "deployment_url": "https://….pages.dev", "created_at": "ISO-8601", "updated_at": "ISO-8601", ... }, ... ] ``` Credit cost: 0 (free, not metered). --- ### GET /v1/sites/:id — fetch one site Returns the full site row, including the current `status`, `generation_phase`, `generation_message`, and (when complete) `deployment_url`. Response (200): full site object (~25 fields including `business_name`, `slug`, `status`, `generation_phase`, `generation_message`, `deployment_url`, `originated_via`, `hosting_tier`, `pause_state`, `last_refreshed_at`, `created_at`, `updated_at`). Errors: - 404 `{ "error": "Site not found" }` — also returned for sites you don't own Credit cost: 0. Polling pattern: if you can't run a webhook receiver, poll this every 2–5 seconds. Stop when `status` is `complete` or `failed`. --- ### POST /v1/sites/:id/refresh — refresh a free-subdomain site Free-subdomain sites auto-pause after 7 days of inactivity. This call bumps `last_refreshed_at = now()` and, if currently paused, restores the live bundle to the edge CDN. Idempotent. Request body: (none — empty `{}` or no body) Response (200): ``` { "site_id": "uuid", "refreshed_at": "ISO-8601", "paused_at": null | "ISO-8601", "was_paused": false | true } ``` Errors: - 404 `{ "error": "site_not_found" }` - 500 `{ "error": "refresh_failed", "detail": "..." }` Credit cost: 0 (free, per spec §2.2). --- ## 2. Revisions ### POST /v1/sites/:id/revisions — edit a deployed site Async. The revision loop picks a fast patch path or a full rewrite path based on scope, then re-deploys. Final result arrives via `site.revision_complete` or `site.revision_failed` webhook. Request body: ``` { "prompt": "Make the hero photo darker and change the headline to ..." } ``` Response (200, 202-like — work runs in background): ``` { "revisionId": "uuid", "status": "pending", "queue_position": 0, // 0 means head of queue "queue_total": 1 } ``` Errors: - 400 `{ "error": "prompt is required" }` - 404 `{ "error": "Site not found" }` - 429 `{ "error": "Daily revision limit reached (...)", "quota": {...} }` - 429 `{ "error": "Let your other edits land first — I've got 3 in the queue already.", "queue_full": true }` - 503 `{ "error": "Revisions are temporarily disabled. Please try again shortly." }` Webhook(s) that fire after this call: - `site.revision_complete` — re-deploy succeeded; includes summary, iterations_used, deployment_url, and `usage.cost_usd` - `site.revision_failed` — last_error + iteration_count - `site.revision_clarification_needed` — agent needs more info before proceeding (returns a `question` field; reply with another revision call) Credit cost: ~20–100 credits per revision, billed at end against actual AI usage. Revisions inside the same site queue serially (depth cap 3 — 1 running + 2 waiting). --- ## 3. Domains ### POST /v1/domains/check — check availability + price Two-stage flow: first call returns availability + suggestions; client picks one then calls `/v1/domains/register`. Request body: ``` { "domain": "acme-plumbing.com", // required "city": "austin", // optional, biases suggestions "industry": "plumbing" // optional, biases suggestions } ``` Response (200): ``` { "domain": "acme-plumbing.com", "available": true, "price_cents": 1099, "suggestions": [ { "domain": "acmeplumbingaustin.com", "available": true, "price_cents": 999 }, ... ] } ``` Credit cost: 0. --- ### POST /v1/domains/register — register + attach a domain Buys the domain via our registrar partner and wires DNS + custom-domain attach in one shot. Price cap: $50/yr ($5000¢). Request body: ``` { "siteId": "uuid", "domain": "acme-plumbing.com", "pagesProjectName": "acme-plumbing-a1b2c3", // = site.slug "price": 1099 // in CENTS, must match check result } ``` Response (200): ``` { "success": true, "domain": "acme-plumbing.com", "zoneId": "cf-zone-uuid", "status": "active", "registeredAt": "ISO-8601", "message": "Domain purchased and connected to your site..." } ``` Errors: - 400 — invalid TLD, domain already taken, price above cap, or missing field - 503 — registrar transient (no funds on operator account, rate limit, WHOIS rejection) Credit cost: 50 credits + the registrar pass-through fee (in cents). --- ### POST /v1/sites/:id/domains — attach a domain you already own Adds an external (operator-owned) domain to a deployed site. Returns the CNAME target the operator should set at their existing registrar. Domain status is `dns_pending` until DNS propagates. Request body: ``` { "domain": "acme-plumbing.com" } ``` Response (200): ``` { "success": true, "domain": "acme-plumbing.com", "cname_target": "acme-plumbing-a1b2c3.pages.dev", "instructions": { "apex": { "type": "CNAME", "name": "@", "value": "acme-plumbing-a1b2c3.pages.dev" }, "www": { "type": "CNAME", "name": "www", "value": "acme-plumbing-a1b2c3.pages.dev" }, "note": "Some registrars don't allow CNAME on the apex..." }, "status": "dns_pending" } ``` Errors: - 400 — invalid domain shape, site not yet deployed - 404 — site not found / not owned by caller - 502 — upstream edge attach failed Credit cost: 5 credits. --- ## 4. Form-submission webhooks (site-level) A generated site's forms POST submissions to a URL you configure per site, signed with HMAC-SHA256 (header `X-Warpweb-Signature`, raw request body). ### POST /v1/sites/:id/webhooks/forms — configure form webhook Request body: ``` { "webhook_url": "https://my-app.example.com/warpweb/forms", "rotate_secret": false // optional; if true, regenerates the signing secret } ``` Response (200): ``` { "ok": true, "site_id": "uuid", "webhook_url": "...", "originated_via": "api", "secret_issued": "whsec_...", // ONLY on first configure or rotate "secret_note": "Copy this signing secret now — it will not be shown again..." } ``` Errors: - 400 — `webhook_url is required and must be http(s)://` - 403 — site not owned by caller - 404 — site not found Credit cost: 0. --- ### POST /v1/sites/:id/webhooks/forms/test — send a synthetic test payload Fires one signed POST to the configured `webhook_url` with a `_test: true` field. Returns the wire-level status (status code, response body, event_id). No retries, no queue. Request body: (none) Response (200): ``` { "ok": true, "status": 200, "response_body": "...", "event_id": "uuid" } ``` Credit cost: 0. --- ## 5. Customer-level lifecycle webhook This is the webhook URL Warpweb posts all *lifecycle* events to (site build/revision status, etc.). Per-site form-submission webhooks above are separate. ### GET /v1/customer/webhook — read current config Response (200): ``` { "url": "https://my-app.example.com/warpweb/lifecycle", "secret_preview": "whsec_...abc1", // last 4 chars only "has_secret": true, "subscribed_events": ["site.complete", "site.failed", "form.submit"], "available_events": [ "site.research_ready", "site.complete", "site.failed", "site.revision_complete", "site.revision_failed", "site.revision_clarification_needed", "form.submit" ], "auth_mode": "hmac" } ``` ### PUT /v1/customer/webhook — set the URL Request body: ``` { "url": "https://my-app.example.com/warpweb/lifecycle" } // or null to clear ``` Response (200): ``` { "url": "...", "secret_preview": "whsec_...abc1", "secret_generated_now": true, "secret_plaintext": "whsec_full_secret_here" // ONLY on first set } ``` ### POST /v1/customer/webhook/regenerate-secret — rotate Response (200): ``` { "secret": "whsec_new_full_secret", "secret_preview": "whsec_...xyz9", "rotated_at": "ISO-8601" } ``` ### PATCH /v1/customer/webhook/subscriptions — pick events Request body: ``` { "events": ["site.complete", "site.failed"] } ``` Response (200): ``` { "subscribed_events": ["site.complete", "site.failed"] } ``` Errors: 400 `unknown_event_type` with `available_events` listed. ### POST /v1/customer/webhook/test-ping — synchronous test Request body (optional): ``` { "event_type": "site.complete" } // pick any canonical type, or omit for webhook.test_ping ``` Response (200): wire-level result (status, response_body, event_id). --- ## 6. Lifecycle webhook event shapes Every webhook POST has these headers: - `Content-Type: application/json` - `User-Agent: Warpweb-Webhook/2.0` - `X-Warpweb-Event-Id: ` (stable across retries — dedupe on this) - `X-Warpweb-Event-Type: ` (e.g. `site.complete`) - `X-Warpweb-Signature: sha256=` - `X-Warpweb-Timestamp: ` (used in the signing string; reject if more than ±300s from "now") Both `site_id` and `occurred_at` are inside the body envelope; receivers should read them from there rather than headers. Envelope: ``` { "event_id": "uuid", "type": "", "site_id": "uuid | null", "occurred_at": "ISO-8601", "payload": { ... type-specific ... } } ``` ### site.research_ready ``` { "research_url": "https://app.warpweb.ai/sites//research", "business_name": "...", "business_category": "septic_services", "siteforge_user_id": "uuid" } ``` ### site.complete ``` { "site_id": "uuid", "deployment_url": "https://….pages.dev", "slug": "acme-example-test", "business_name": "...", "siteforge_user_id": "uuid", "usage": { "cost_usd": 1.42, "tokens_in": 85421, "tokens_out": 12804 } } ``` ### site.failed ``` { "site_id": "uuid", "failed_phase": "research" | "generation" | "review" | "deploy", "failed_reason": "sdk_error" | "quota" | "invalid_input" | ..., "last_error": "human-readable string", "siteforge_user_id": "uuid" } ``` ### site.revision_complete ``` { "site_id": "uuid", "revision_id": "uuid", "deployment_url": "https://….pages.dev", "summary": "Updated hero copy and added a Services section...", "iterations_used": 3, "siteforge_user_id": "uuid", "usage": { "cost_usd": 0.41, "tokens_in": 24113, "tokens_out": 4902 } } ``` ### site.revision_failed ``` { "site_id": "uuid", "revision_id": "uuid", "reason": "out_of_scope" | "typecheck_iteration_cap" | "sdk_error" | "queue_timeout" | "infrastructure" | "cancelled" | ..., "reason_detail": "human-readable explanation (out_of_scope only)", "last_error": "TS2322 in app/page.tsx:42", "suggestion": "in-scope alternative (out_of_scope only)", "failed_at": "ISO-8601", "iteration_count": 8, "siteforge_user_id": "uuid" } ``` `reason` is the machine code; `reason_detail` (when present, currently only on `out_of_scope`) carries the human-readable explanation. For stable machine-readable branching prefer `failed_phase` (also emitted; spec §6.4). ### site.revision_clarification_needed ``` { "site_id": "uuid", "revision_id": "uuid", "question": "You asked me to update the phone number, but ...", "context": "Header: (555) 010-1111 — Contact: (555) 010-2222 — ...", "siteforge_user_id": "uuid" } ``` Resolve by sending a new `POST /v1/sites/:id/revisions` call with the clarifying answer in the prompt. ### form.submit (also sent to per-site form webhook URL) ``` { "site_id": "uuid", "submitted_at": "ISO-8601", "name": "Jane Test", "email": "jane.test@example.invalid", "phone": "(555) 010-9999", "message": "...", "raw_fields": { "name": "...", "email": "...", "phone": "...", "message": "...", "service_type": "pumping", "preferred_time": "morning" } } ``` ### webhook.test_ping (synthetic, not subscribable) Fired only by the `test-ping` endpoints (`POST /v1/customer/webhook/test-ping`, `POST /v1/sites/:id/webhook/test-ping`) when `event_type` is omitted. Receivers should accept it and no-op. A router that rejects unknown event types should special-case this string. ``` { "message": "This is a test ping from Warpweb. Your webhook is working.", "sent_by": "warpweb_dashboard_test_ping", "target": "customer_lifecycle" } ``` --- ## 7. Signature verification Lifecycle webhooks use HMAC-SHA256 over `${timestamp}.${raw_body}` with the customer's webhook secret. Per-site form-submission webhooks use the same scheme keyed by the per-site `secret_issued`. The header value is `sha256=` and lives in `X-Warpweb-Signature`. The timestamp lives in `X-Warpweb-Timestamp` (unix seconds) and MUST be within ±300 seconds of the receiver's wall clock — otherwise the request is a replay and must be rejected. Node.js example (matches the producer byte-for-byte): ``` import { createHmac, timingSafeEqual } from 'crypto' const REPLAY_WINDOW_SECONDS = 300 function verify( rawBody: string, signatureHeader: string, // value of X-Warpweb-Signature, e.g. "sha256=abc..." timestampHeader: string, // value of X-Warpweb-Timestamp, e.g. "1716045731" secret: string, ): { valid: true } | { valid: false; reason: string } { if (!signatureHeader.startsWith('sha256=') || !timestampHeader) { return { valid: false, reason: 'missing_headers' } } const ts = Number(timestampHeader) if (!Number.isFinite(ts)) { return { valid: false, reason: 'bad_timestamp' } } const now = Math.floor(Date.now() / 1000) if (Math.abs(now - ts) > REPLAY_WINDOW_SECONDS) { return { valid: false, reason: 'stale_timestamp' } } const signingString = `${ts}.${rawBody}` const expectedHex = createHmac('sha256', secret).update(signingString).digest('hex') const providedHex = signatureHeader.slice('sha256='.length) if (providedHex.length !== expectedHex.length) { return { valid: false, reason: 'length_mismatch' } } const a = Buffer.from(providedHex, 'hex') const b = Buffer.from(expectedHex, 'hex') if (a.length !== b.length || !timingSafeEqual(a, b)) { return { valid: false, reason: 'sig_mismatch' } } return { valid: true } } ``` Critical rules (don't deviate): - Sign / verify against the **raw** request body bytes as received over the wire. Never re-serialize parsed JSON before computing HMAC — key order and whitespace will drift and your signature won't match. - The signing input is `${timestamp}.${raw_body}` with a literal `.` — not just the body. Skipping the timestamp prefix is the single most common mistake. - Enforce the ±300s replay window. Without it, a captured request can be replayed indefinitely. - Strip the `sha256=` prefix before comparing the hex digest. - Use a constant-time comparison (`timingSafeEqual` / `hmac.compare_digest`). Plain `===` leaks timing. ### Test vector (deterministic) Use these values to confirm your verifier matches the producer byte-for-byte before pointing it at a live Warpweb deployment. ``` secret = "whsec_test_vector_do_not_use_in_production" timestamp = "1716045731" raw_body = '{"event_id":"00000000-0000-0000-0000-000000000008","type":"webhook.test_ping","site_id":null,"occurred_at":"2026-05-18T15:00:00.000Z","payload":{"message":"This is a test ping from Warpweb. Your webhook is working.","sent_by":"warpweb_dashboard_test_ping","target":"customer_lifecycle"}}' ``` Expected `X-Warpweb-Signature`: `sha256=230b7beb57702d119dd6fb31cc8c8abf1482b6f6fa5213f61620275fb140331e` Reproduce with curl + openssl: ```bash SECRET="whsec_test_vector_do_not_use_in_production" TS="1716045731" RAW_BODY='{"event_id":"00000000-0000-0000-0000-000000000008","type":"webhook.test_ping","site_id":null,"occurred_at":"2026-05-18T15:00:00.000Z","payload":{"message":"This is a test ping from Warpweb. Your webhook is working.","sent_by":"warpweb_dashboard_test_ping","target":"customer_lifecycle"}}' printf '%s.%s' "$TS" "$RAW_BODY" \ | openssl dgst -sha256 -hmac "$SECRET" \ | awk '{print "sha256="$2}' ``` If your verifier accepts this signature for that exact `raw_body` + `timestamp` and rejects any mutation of either, you're aligned with the producer. --- ## 8. Error codes - 400 `Bad Request` — missing/invalid field; `{ "error": "..." }` - 401 `Unauthorized` — missing/invalid `Authorization: Bearer ...` - 402 `Payment Required` — `{ "error": "PAYMENT_REQUIRED", "reason": "balance_zero_no_auto_refill", "resolution": "Add credits at https://warpweb.ai/app/credits or enable auto-refill." }` - 403 `Forbidden` — caller doesn't own the resource (or for endpoint not in V1 scope on legacy paths) - 404 `Not Found` — resource not found OR endpoint not in V1 scope (`endpoint_not_in_v1_scope`); deliberately conflated to avoid leaking which API keys/paths exist - 429 `Too Many Requests` — daily quota hit (sites/revisions/domains) OR per-site revision queue full (`queue_full: true`) - 500 `Internal Server Error` — unexpected; safe to retry - 502 `Bad Gateway` — upstream edge failure during domain attach - 503 `Service Unavailable` — temporary maintenance (revisions disabled) OR registrar transient (no funds, rate limit, WHOIS rejection) --- ## 9. Pricing reference Credits (one-time Stripe Checkout, no subscription): - Starter: $5 / 100 credits - Builder: $25 / 600 credits (16% bulk discount vs Starter rate) - Pro: $100 / 2,800 credits (29% bulk discount vs Starter rate) Active Site monthly subscription (Stripe quantity-based subscription, only when a site has a custom domain attached): - $10 / site / month — covers always-on hosting, SSL auto-renew, monitoring, webhook delivery, form forwarding Free tier: - 520 credits free on signup with email verification (no card required; enough for one solid build plus a few revisions) - Free Warpweb subdomain hosting (`*.warpweb.app`) — auto-pauses after 7 days of inactivity, free `POST /v1/sites/:id/refresh` to revive Per-call directional credit costs: - `POST /v1/sites` — ~200–500 credits (billed at end-of-build against actual AI usage; `usage.cost_usd` returned in `site.complete` payload) - `POST /v1/sites/:id/revisions` — ~20–100 credits per revision - `POST /v1/sites/:id/domains` — 5 credits - `POST /v1/domains/register` — 50 credits + registrar pass-through fee - `POST /v1/domains/check` — 0 - All `GET` endpoints — 0 - All webhook configure / test-ping endpoints — 0 --- ## 10. Idempotency + retries - `POST /v1/sites/:id/refresh` — idempotent (safe to retry any number of times) - `POST /v1/sites/:id/webhooks/forms` configure with same `webhook_url` — idempotent (won't re-issue secret unless `rotate_secret: true`) - `POST /v1/sites/:id/domains` (external attach) — idempotent at the edge layer; safe to retry - `POST /v1/sites` — NOT idempotent; each call kicks off a fresh build. If you need idempotency, dedupe on your side by `business_name + contact_email` - Lifecycle webhook deliveries — dedupe on `event_id` (UUID, stable across retries) --- ## 11. When NOT to use Warpweb - **Your business doesn't have a Google Places listing** — typical for SaaS, agencies, and fully-remote/software-only companies. The API still works, but research falls back to your `businessDescription` and the result reads more generic. Warpweb is calibrated for service-based local businesses (trades, clinics, salons, agencies, restaurants, real estate). SaaS / portfolio / ecommerce site_type tracks are on the roadmap. - You want hand-coded sites with full template control → use Next.js / Astro yourself; Warpweb owns the design + content generation step - You need an in-app editor UI for end-users — Warpweb is API-only - Static personal sites that never change — a bare static-host setup is cheaper - You need a website builder with drag-and-drop — wrong shape; Warpweb is a generation API, not a builder - Sites that must be served from a specific origin you control — Warpweb deploys to its own managed edge CDN --- ## 12. Surfaces - Marketing: https://warpweb.ai - Pricing: https://warpweb.ai/#pricing - Sign up: https://warpweb.ai/signup - API key management: https://warpweb.ai/app - Docs (extended): https://docs.warpweb.ai - Terse reference: https://warpweb.ai/llms.txt