chatformdocs

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

EventWhen
response.completedSomeone finished
response.disqualifiedThe form screened someone out: they reached a screen-out ending
response.abandonedA response was given up on or timed out
response.partialA response has stalled part-way, with answers in it
response.resumedSomeone came back to a response they had abandoned
followup.sentA follow-up reminder went out
response.answer_recordedAn answer was saved, in a conversation, over the API or by a payment
session.startedA conversation opened
form.publishedA 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:

Eventdata
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 429 or 503 with a Retry-After header waits as long as it asks, up to a day.
  • A 410 Gone stops 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.

EndpointWhat it does
GET /v1/webhooks/statsPending, 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}/deliveriesThe 50 most recent deliveries, newest first, each with every attempt. Filter with ?status=pending, failed or success
POST /v1/webhooks/{id}/deliveries/{deliveryId}/replaySend 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-failedPut 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-id is 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-delivery names 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.

On this page