Webhooks
Get told when something happens, instead of asking.
Point us at a URL and we will POST to it. Configure endpoints per form or across your whole organization.
Events
| Event | When |
|---|---|
response.completed | Someone finished |
response.abandoned | A response was given up on or timed out |
response.partial | A response has stalled part-way, with answers in it |
response.resumed | Someone came back to a response they had abandoned |
followup.sent | A follow-up reminder went out |
session.started | A conversation opened |
form.published | A new version went live |
response.partial is the one people miss. Until it existed, a half-finished lead
was invisible until it timed out — which is exactly as long as it was worth
following up on. It fires once a response has stopped changing for a minute, and
again only if more answers arrive.
response.abandoned is no longer terminal. With follow-ups
switched on, a respondent can return days later through the link in a reminder —
so you will see response.abandoned, then response.resumed, then
response.completed, all carrying the same submissionId. If your handler
treats an abandonment as the end of that record — closing the lead, deleting the
draft, marking it lost — subscribe to response.resumed and undo it there.
Subscriptions written against the old submission.completed and
submission.abandoned names keep working. Both names match the same event.
The payload
{
"event": "response.completed",
"formId": "frm_…",
"timestamp": 1788505749473,
"submission": { "id": "sbm_…", "status": "completed", "duration_ms": 48210 },
"answers": [
{ "ref": "q_email", "type": "email", "value": "maya@northwind.co" }
]
}Verifying a delivery
Every delivery is signed. Verify it — an unverified webhook endpoint is a public API that writes to your database.
Headers follow the Standard Webhooks spec:
webhook-id: whd_…
webhook-timestamp: 1788505749
webhook-signature: v1,<base64 HMAC-SHA256>The signed content is {id}.{timestamp}.{body}, so a replayed body with a fresh
id fails.
import crypto from "node:crypto";
function verify(rawBody, headers, secret) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signature = headers["webhook-signature"];
// Reject anything older than five minutes, before doing real work.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${id}.${timestamp}.${rawBody}`)
.digest("base64");
return signature
.split(" ")
.some((part) =>
crypto.timingSafeEqual(Buffer.from(part.replace("v1,", "")), Buffer.from(expected)),
);
}Verify against the raw body. Parsing and re-serialising changes the bytes, and the signature is over the bytes.
Retries
A delivery that fails is retried after 1 minute, 5 minutes, 30 minutes and 2 hours, then marked dead. Any 2xx counts as success.
Endpoints that fail twenty times in a row are switched off. That is a courtesy — an endpoint gone that long is not coming back on its own, and retrying every event forever turns a dead integration into an incident.
Handling them well
- Be idempotent. Deliveries are at-least-once.
webhook-idis unique per delivery; recording the ones you have processed is the simplest way. - Answer quickly. We wait ten seconds. Acknowledge, then do the work.
- Do not infer order. A retried event can arrive after a newer one. Use
timestamp, or re-read the response.