API ReferencePOST /v1/sites/:id/contact

POST /v1/sites/:id/contact

Update the contact email for a live site. Affects two things:

  1. Form-submission delivery routingsites.contact_email is the inbox where contact-form submissions get delivered. This endpoint always updates the column. The next form submission delivers to the new address regardless of what’s painted on the HTML.
  2. The visible email on the page — when the site has shipped AND there was a prior email in the rendered HTML, Warpweb walks the site’s HTML files and replaces every occurrence of the old email with the new one (mailto: hrefs, plain-text displays, schema.org structured-data fields). A fresh Cloudflare Pages deploy follows so the change goes live.

This is the post-build counterpart to contactEmail on POST /v1/sites/:id/approve-research — once the site is shipped, use this endpoint instead.

Cost: Free. Deterministic find-replace + redeploy; no LLM.

When to use it

  • Operator originally typed their own email (or Prickl filled it in with theirs) and now has the customer’s real address.
  • Operator wants to change which inbox receives form submissions for any reason.
  • An automated flow detected email_status: "missing" or "placeholder" on the site.research_ready or site.complete webhook and wants to fill the gap.

Request

curl -X POST https://api.warpweb.ai/v1/sites/<site_id>/contact \
  -H "Authorization: Bearer wwk_<your-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "contactEmail": "jjseptic@hotmail.com"
  }'

Body

FieldTypeRequiredDescription
contactEmailstringyesThe new contact email. Must look like name@example.com (trimmed, contains @, has a . after @). Whitespace-only / missing @ / no domain → 400.
syncHtmlbooleanno, default trueWhen true and the site has shipped and there’s a prior email in HTML, Warpweb walks the staged HTML files and replaces every occurrence before re-deploying. Pass false to skip — only sites.contact_email (the form-submission delivery routing) is updated, the rendered HTML is left alone.

Response

Success — full sync

{
  "ok": true,
  "contactEmail": "jjseptic@hotmail.com",
  "emailStatus": "confirmed",
  "syncedHtml": true,
  "filesUpdated": 2,
  "deploymentUrl": "https://jj-septic-llc-dea19a.pages.dev/"
}

emailStatus is the three-state signal — see Email resolution. filesUpdated is the count of HTML files actually rewritten.

Success — DB updated, HTML sync skipped

When something prevents the deterministic find-replace, the DB still updates and the response carries htmlSyncSkipped + an actionable hint:

htmlSyncSkippedWhenWhat to do
opted_outCaller passed syncHtml: false.Nothing — you opted out.
not_yet_shippedSite is still in researching / generating / reviewing / deploying. The generator reads sites.contact_email when it runs, so the new value is picked up automatically.Nothing — the in-flight build will use the new value.
no_prior_email_in_htmlThe site shipped with no email rendered in HTML — there’s no anchor for find-replace. Common when the site uses a contact form without displaying a mailto: link.Form submissions now route to the new address. To also show the email on the page, fire POST /v1/sites/:id/revisions with a prompt like "Add a contact email to the page footer pointing to <new>".
{
  "ok": true,
  "contactEmail": "jjseptic@hotmail.com",
  "emailStatus": "confirmed",
  "syncedHtml": false,
  "filesUpdated": 0,
  "htmlSyncSkipped": "no_prior_email_in_html",
  "hint": "Form submissions now deliver to jjseptic@hotmail.com. To also show the address on the page, fire POST /v1/sites/<site_id>/revisions with prompt: \"Add a contact email to the contact page and footer pointing to jjseptic@hotmail.com.\""
}

Success — no-op

When the new email matches the existing value exactly (case-insensitive on the domain), the endpoint short-circuits:

{
  "ok": true,
  "contactEmail": "jjseptic@hotmail.com",
  "emailStatus": "confirmed",
  "syncedHtml": false,
  "filesUpdated": 0,
  "noop": true
}

Safe to call repeatedly — idempotent.

Partial success — DB updated, HTML sync failed

If the find-replace + re-deploy throws after the DB update succeeded, you get a 502:

{
  "error": "html_sync_failed",
  "detail": "<underlying error message>",
  "contactEmailUpdated": true,
  "hint": "Form submissions now deliver to <new> (DB updated). HTML sync failed; retry POST /v1/sites/<id>/contact with the same body to retry the visible-display sync."
}

The form-submission delivery routing change did land — the next submission goes to the new address. The visible HTML still shows the old email. Retry the same POST to retry the find-replace + deploy.

Errors

StatuserrorWhen
400contactEmail is required (string)Body missing or non-string contactEmail.
400contactEmail cannot be emptyBody had contactEmail but it was whitespace-only.
400contactEmail must look like name@example.comDoesn’t contain @, or no domain after @, or no . in the domain.
400Request body must be valid JSONBody not parseable as JSON.
404site_not_foundSite doesn’t exist OR belongs to a different caller. (Identical shape for both — we don’t leak existence of other callers’ sites.)
500db_update_failedThe UPDATE sites SET contact_email = … query itself failed. Retry.
502html_sync_failedDB update landed; HTML find-replace or Cloudflare deploy errored. contactEmailUpdated: true confirms the routing change took effect. Retry to retry the HTML sync.

Limitations

Substring replacement, not RFC 5322 boundary matching. The endpoint replaces every occurrence of the old email string in HTML files. If one of your pages has both old@example.com and very-old@example.com, the substring old@example.com inside the longer address ALSO gets replaced. In practice contact pages rarely list multiple similar addresses, but if you hit this, fire a follow-up revision to clean up.

Same-domain rewriting depends on the find-replace anchor. The endpoint doesn’t know the structure of your page — it only finds-and-replaces the exact old-email substring. If the HTML displayed the email in some unexpected encoding (e.g. JavaScript-obfuscated info AT example DOT com), the find-replace won’t catch it. In that case filesUpdated: 0 is returned with htmlSyncSkipped: 'no_prior_email_in_html', and a revision is the right tool.

No file-extension scan beyond HTML. CSS, JS bundles, and JSON manifests are not walked. The deployed site is the only file-shape that displays the email to end-users; if your generator emits the email into a JS bundle (unusual), that bundle won’t be rewritten — fire a revision for that case too.

Also available via revisions

The same atomic “fix both routing and visible HTML” logic is exposed to the revision-AI loop as a tool called set_contact_email. If the operator says “change the email to X” in a chat that’s wired up to fire revisions, the AI uses this tool to keep the DB column and visible HTML in sync — the same outcome as calling this endpoint directly. No special endpoint call needed from the chat layer; just send the operator’s prompt to POST /v1/sites/:id/revisions and let the AI route it.

The endpoint and the tool share the same underlying logic (applyContactEmailChange in SiteForge’s services/contact-email-update.ts), so behavior — no-anchor handling, case-insensitive matching, idempotency — is identical between the two entry points. Use the endpoint for direct API calls from your own backend; let the AI use the tool when the operator’s intent comes through chat.