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.disqualified | The form screened someone out: they reached a screen-out ending |
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 |
response.answer_recorded | An answer was saved, in a conversation, over the API or by a payment |
session.started | A conversation opened |
form.published | A new version went live |
A screened-out respondent gets response.disqualified, never
response.completed, so a "completed" handler that adds people to a CRM or
sends a welcome email does not fire for someone the form turned away.
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" }
]
}Events about a response carry submission and every answers entry so far.
The other three add a data object instead, or alongside:
| Event | data |
|---|---|
response.answer_recorded | { ref }: the question just answered. answers already includes it |
session.started | { sessionId, source }. No submission: nothing has been answered yet |
form.published | { version, versionId, note } |
response.answer_recorded fires on every answer, so it is the busiest event by
far. Subscribe to it only when you need answers as they arrive; response.partial
and response.completed cover most integrations with a fraction of the calls.
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: evt_…
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. The HMAC key is the part of your secret after whsec_, base64-decoded,
which is what every Standard Webhooks library does.
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;
// The key is the base64 after `whsec_`, decoded. Not the characters.
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = Buffer.from(
crypto.createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64"),
);
return signature.split(" ").some((part) => {
const given = Buffer.from(part.replace("v1,", ""));
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}Verify against the raw body. Parsing and re-serialising changes the bytes, and the signature is over the bytes.
Retries
Every event goes into a delivery queue, with one delivery per endpoint. A delivery that fails is retried on its own, after about 1 minute, 5 minutes, 30 minutes, 2 hours and 8 hours (each with a little random jitter), so six attempts over roughly ten hours. Only a 2xx counts as success. Redirects are not followed.
- A
429or503with aRetry-Afterheader waits as long as it asks, up to a day. - A
410 Gonestops at once and switches the endpoint off. - After the last retry the delivery moves to the Failed list in the Integrate tab, where you can retry it, or all of them, once your server is fixed. Failed and delivered deliveries are kept for 30 days.
Endpoints that fail twenty attempts in a row are switched off. Their pending deliveries move to the Failed list, so turning the endpoint back on and pressing Retry all recovers everything it missed.
From the API
Everything the Integrate tab does is also an endpoint. Reading needs
webhook:read; changing anything needs webhook:write.
| Endpoint | What it does |
|---|---|
GET /v1/webhooks/stats | Pending, failed and delivered-in-the-last-24-hours counts, per endpoint and in total. Add ?formId= for one form's endpoints |
GET /v1/webhooks/{id}/deliveries | The 50 most recent deliveries, newest first, each with every attempt. Filter with ?status=pending, failed or success |
POST /v1/webhooks/{id}/deliveries/{deliveryId}/replay | Send one delivery again now. A failed one gets a fresh round of retries; a delivered one is sent again as a copy with the same webhook-id |
POST /v1/webhooks/{id}/retry-failed | Put every failed delivery of the endpoint back in the queue, up to 500 at a time. Returns { ok, queued } |
PATCH /v1/webhooks/{id} | { "active": true } or false. Turning an endpoint on clears its failure count |
The recovery after an outage is two calls:
curl -X PATCH https://api.chatform.in/v1/webhooks/$WEBHOOK_ID \
-H "x-api-key: $CHATFORM_SECRET_KEY" \
-H "content-type: application/json" \
-d '{"active": true}'
curl -X POST https://api.chatform.in/v1/webhooks/$WEBHOOK_ID/retry-failed \
-H "x-api-key: $CHATFORM_SECRET_KEY"A test send from the dashboard is never replayed and never counted in the stats.
Handling them well
- Be idempotent. Deliveries are at-least-once.
webhook-idis the event's id, the same on every retry and on a manual retry; recording the ones you have processed is the simplest way.x-chatform-deliverynames the delivery. - Answer quickly. We wait fifteen 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.