Form Submissions
When someone submits a contact form on a deployed Warpweb site, the data POSTs to the URL you configured via POST /v1/sites/:id/webhooks/forms.
Outbound request
POST https://api.yourapp.com/webhooks/warpweb-leads
Content-Type: application/json
User-Agent: Warpweb-Webhook/1.0
X-Warpweb-Event-Id: 7d0b368c-b452-cf9a-1234-56789abcdef0
X-Warpweb-Site-Id: 8f3c2a1b-5d47-4c9e-b820-1f8a3e7d9c4f
X-Warpweb-Signature: sha256=a3f9e2c1d4b5a6f7e8d9c0b1a2f3e4d5c6b7a8f9e0d1c2b3a4f5e6d7c8b9a0f1Headers
| Header | Description |
|---|---|
X-Warpweb-Event-Id | UUID for this delivery. Use as your idempotency key. Duplicates can arrive if your previous 2xx response was lost in transit. |
X-Warpweb-Site-Id | The originating site’s ID. Context aid; doesn’t replace looking up which secret to verify against. |
X-Warpweb-Signature | HMAC-SHA256 of the raw body, hex-encoded, with sha256= prefix. Same scheme as Stripe. See Verifying Signatures. |
Body
{
"event_id": "7d0b368c-b452-cf9a-1234-56789abcdef0",
"event_type": "form_submission",
"site_id": "8f3c2a1b-5d47-4c9e-b820-1f8a3e7d9c4f",
"submitted_at": "2026-05-17T15:42:11Z",
"name": "Jane Doe",
"email": "jane@example.com",
"phone": "+15125550199",
"message": "Hi, I need a leaky pipe fixed in my kitchen. Available Tuesday afternoon.",
"raw_fields": {
"full_name": "Jane Doe",
"email_address": "jane@example.com",
"phone_number": "+15125550199",
"message": "Hi, I need a leaky pipe fixed in my kitchen. Available Tuesday afternoon.",
"preferred_contact": "phone"
}
}Top-level fields
| Field | Type | Description |
|---|---|---|
event_id | string | UUID. Idempotency key. |
event_type | string | Always "form_submission". |
site_id | string | The site that received the submission. |
submitted_at | string | ISO 8601 UTC. |
name | string | null | Best-effort extraction from the raw form. See matching rules below. |
email | string | null | Same. |
phone | string | null | Same. |
message | string | null | Same. |
raw_fields | object | All form fields as the visitor submitted them, lossless. Matched fields are duplicated here for safety. |
Field extraction
Warpweb-generated forms use varied field names depending on the site’s design. We extract the four common fields case-insensitively:
| Top-level field | Matches |
|---|---|
name | name, fullname, full_name, your_name, contact_name |
email | email, email_address, e-mail, mail |
phone | phone, phone_number, tel, mobile, cell |
message | message, comments, inquiry, details, notes. If multiple match, the longest text field wins. |
If extraction can’t find a match, the top-level field is null — but the original is still in raw_fields. Treat raw_fields as the source of truth if you need the visitor’s exact input.
Signature
HMAC-SHA256 of the raw body bytes (before any JSON parsing), keyed by your stored webhook secret, hex-encoded, prefixed with sha256=. Identical to Stripe’s signing scheme.
Important: Verify against the raw body, not the re-serialized JSON. JSON re-serialization changes whitespace and field ordering and breaks the signature.
See Verifying Signatures for Node, Python, and curl examples.
Retry behavior
| Outcome | Retry? |
|---|---|
| HTTP 2xx | success — done. |
| HTTP 4xx (including 400, 401, 403, 404, 410, 422) | NO retry. Logged and dropped. |
| HTTP 5xx | retry. |
| Network error / connection refused / DNS failure | retry. |
| Receiver timeout > 10s | treated as 5xx — retry. |
Retry schedule: immediate, +30s, +5min, then dead-letter. Dead-letter rows appear in the dashboard with a manual replay button.
If you return a 4xx by mistake, the submission is gone (manual replay aside) — don’t return 4xx to “ignore” events. Return 200 and ignore on your end.
Idempotency
Use event_id as the dedupe key. If the same event_id arrives twice (your 200 got lost in transit, our retry fired), respond 200 immediately without re-processing — don’t create duplicate leads in your CRM.
A common pattern:
const seen = await db.webhookEvents.findUnique({ where: { event_id } })
if (seen) return res.status(200).send('ok') // already processed
await db.webhookEvents.create({ data: { event_id } })
// ...process the leadDead-letter recovery
If all retries fail, the submission lands in your dashboard’s dead-letter queue with the full payload, your endpoint’s last response, and a “Replay” button. Replays generate a new event_id — your receiver will treat it as a fresh delivery, not a retry.