Developers
Webhook API reference
Payload schema, event types, signing, retry behavior, and the REST endpoints any automation tool subscribes through.
Looking for the setup guide?
This page documents the payload contract and REST subscription API. To set up a custom webhook for your form in the UI, see Custom webhooks.
Request
POST <your-url> with Content-Type: application/json.
Headers
Content-Type: application/jsonX-formbase-Signature: t={timestamp},sha256={hex}— present when a signing secret is set up (see below)X-formbase-Event-IdandX-formbase-Event-Type— the same values asidandtypein the body, so you can dedupe and route before parsing. A request callback sends the same two.Any custom headers you add during setup. They are merged in as given, except
Content-Type, which cannot be overridden. Native subscriptions created throughwebhooks.createhave no custom headers.
Event types
Subscription events
When you set up a webhook integration, you choose which event triggers deliveries:
| Event | When it fires |
|---|---|
| submission_created | A respondent completes and submits the form. This is the default. |
| submission_updated | A respondent edits a submission they already sent, when the form allows editing after submit. |
| submission_abandoned | A draft submission has been idle past the configured window. Requires partial submission tracking (Pro). |
| request_completed | A recipient completes a request on the form. Carries the request block and the answers. |
| request_expired | A request on the form expires before the recipient completes it. Request block only. |
| request_canceled | A request on the form is canceled. Request block only. |
The three request_* events are subscribed through webhooks.create and are what the formbase apps for Zapier,
Make and n8n listen on. Each delivers the same envelope a request callback sends, signed with the
subscription’s own secret. Test requests never reach a subscription, and requests.replayCallback re-sends the callback only.
One subscription, one event: submission_created is share-link traffic and request_completed is request traffic,
so a completed request never fires submission_created and a form with both subscriptions receives one delivery per
completion. An edit reaches a submission_updated subscription alone, never a submission_created one. The webhook
you configure in Form settings has no event choice: it receives first submissions and edits alike, told apart by type.
Payload event types
The type field in the JSON body tells you what happened:
submission.completed— a new completed submissionsubmission.updated— an existing submission was editedsubmission.abandoned— a draft submission was abandoned after the configured idle window
A request callback and a request_* subscription use the same envelope with
request.completed, request.expired and request.canceled, so one parser reads all six.
Payload shape
{
"id": "evt_abc123",
"type": "submission.completed",
"createdAt": "2026-04-25T12:34:56.000Z",
"apiVersion": "2026-09-24",
"test": false,
"data": {
"form": { "id": "frm_...", "name": "Contact", "snapshotId": "snp_..." },
"submission": {
"id": "sub_...",
"respondentEmail": "alice@example.com",
"submittedAt": "2026-04-25T12:34:56.000Z",
"updatedAt": null,
"editCount": 0,
"pdfUrl": null,
"language": "en"
},
"answers": {
"email": "alice@example.com",
"plan": "pro",
"attendees": [{ "attendee_name": "Grace Hopper" }, { "attendee_name": "Alan Turing" }]
},
"display": {
"email": "alice@example.com",
"plan": "Pro",
"attendees": "Grace Hopper, Alan Turing"
}
}
}| Key | What it is |
|---|---|
| id | The event id. Retries reuse it — deduplicate on it. |
| type | One of the six event types above. |
| createdAt | When the event was queued, not when this delivery attempt was made. Stable across retries. |
| apiVersion | The payload contract, as a date. It changes when a key is removed, renamed or changes meaning. New keys arrive without a change. |
| test | true for a sample or test delivery, and for a request created in test mode. Always present. |
| data.form.snapshotId | The published version the respondent answered. Field keys, titles and types are fixed per snapshot. |
| data.submission.updatedAt | When the respondent last edited the submission, or null until the first edit. |
| data.submission.editCount | How many times the respondent edited the submission after sending it: 0 on submission.completed, 1 on the first edit. |
| data.answers | Every answer, keyed by field key. Each answer appears once. |
| data.display | Human-readable text for every answer, under the same keys. |
| data.schema | Optional: the field list (key, title, type, group, and the option or row and column keys with their labels), when the webhook was set up to send it. |
answers and display
answers is the flat { field key: value } object, keyed by the field keys frozen at publish. Read it when a
workflow branches or stores a value: answers.email, no array to walk. A choice answer is the chosen option’s
key — the key that fields.list lists for that option — so it is the same whatever language the
respondent answered in. A date is an ISO string, a number a number, a multi-select an array of option keys. Unanswered fields are left
out, never sent as null.
display carries the same keys with human-readable text: the option label instead of its key, a formatted date, a joined list.
Read it when a person will see the value — a Slack message, a spreadsheet cell, an email.
A repeating group appears in answers once, under the group’s own field key, as an array of row objects keyed by each member’s
field key — answers.attendees[0].attendee_name above — and in display as one line with the rows joined. A member
is never hoisted to the top level. Calculated fields appear in both maps under the
calculated field’s name as its key (answers.total).
Bookings and payments
A Schedule appointment question and a Payment question each carry an object in answers, under the question’s field key, and
one line of text in display. The times are ISO instants, so a spreadsheet or a workflow can parse them whatever language the
respondent answered in:
{
"book_a_call": {
"status": "confirmed",
"start": "2026-09-29T07:00:00.000Z",
"end": "2026-09-29T07:30:00.000Z",
"timeZone": "Europe/Oslo",
"attendee": { "name": "Grace Hopper", "email": "grace@example.com" },
"meetingUrl": "https://app.cal.com/video/...",
"provider": "cal.com",
"providerBookingId": "...",
"eventTitle": "Intro call"
},
"pay_the_fee": {
"status": "paid",
"amount": 40,
"currency": "USD",
"amountRefunded": 0,
"receiptUrl": "https://pay.stripe.com/receipts/...",
"paidAt": "2026-09-24T10:12:00.000Z",
"refundedAt": null,
"disputedAt": null,
"provider": "stripe",
"providerPaymentIntentId": "pi_..."
}
}A booking’s status is confirmed, rescheduled, cancelled, rejected or
no_show. A payment’s is paid, partially_refunded, refunded or disputed,
and amount is in the currency’s main unit: 40 is $40.00. When Cal.com moves a booking or Stripe refunds a
payment after the submission, formbase updates the object, so submissions.list and later events show the current state; no
new event is sent for the change.
Before apiVersion 2026-09-24, a booking was sent as one sentence in answers and a payment was not
sent at all.
The webhook’s field mapping applies to both maps at once: choose “selected” fields and the others are left out; rename a field’s column
and the new name is its key in answers and display alike. A field the form published before field keys existed
goes out under its element id; publish the form again to give it a readable key.
Field titles and types
The event does not repeat each field’s title and type. Read them from fields.list, which is stable per
data.form.snapshotId, so you can cache the field list and refetch only when the snapshot id changes. A receiver that cannot
make a second call can turn on Send the field list with every event in the webhook’s settings; the event then carries
data.schema, one entry per field. A choice question lists its options and a matrix its rows and
columns, each as { key, label }, so the keys in answers resolve to labels without a second
call:
[
{ "key": "email", "title": "Email", "type": "email", "group": null },
{ "key": "plan", "title": "Plan", "type": "radio", "group": null, "options": [{ "key": "free", "label": "Free" }, { "key": "pro", "label": "Pro" }] },
{ "key": "attendee_name", "title": "Attendee name", "type": "text", "group": "attendees" }
]The request block
On the custom webhook configured in form settings, a submission that answered a
request carries one extra object inside data, request. It is absent on every
public-link submission, so its presence is how that receiver tells the two channels apart. A Zapier, Make or n8n subscription never sees
it: request traffic reaches a subscription as request.completed, which carries the full request block.
"request": {
"id": "kd7...",
"externalId": "run-42",
"metadata": { "crm_record": "rec_88" }
}id— the request this submission answered. Pass it torequests.getfor the full picture.externalIdandmetadata— your own bookkeeping, exactly as you supplied it onrequests.create. Each is present only when it was set.
Webhooks are not callbacks
A submission webhook fires on a submission; the request block only names the request it answered. A
callback fires when a request ends — completed, expired, or canceled — and carries
context and outcome. Expiry and cancellation have no submission, so no submission webhook ever fires for them.
To hear a request end without a callback URL, subscribe to request_completed, request_expired or
request_canceled through webhooks.create.
Submission PDF
data.submission.pdfUrl is a link to the submission PDF. A form with a custom webhook or a Zapier, Make or n8n subscription
keeps a PDF of every submission, so their events carry the link. It is null only when the submission has no PDF.
Submission language
data.submission.language is the BCP-47 code of the language the respondent submitted in (for translated forms). It is
null for single-language forms. Use it to route or branch on the respondent’s language without a separate lookup.
Abandoned submission payloads
When you subscribe to submission_abandoned, formbase checks for idle drafts every hour. If a draft has been inactive past the
configured idle window, formbase fires a delivery.
Native API integrations must send idleWindow to webhooks.create. Accepted values are 12h,
1d, 3d, and 1w. There is no implicit default; an abandoned subscription without a value is
rejected.
| Idle window | Description |
|---|---|
| 12 hours | For same-day follow-ups |
| 1 day | A reasonable gap before nudging |
| 3 days | For less urgent forms |
| 1 week | For low-frequency forms |
The payload shape is identical to a completed submission. Two differences:
Answers may be sparse — only questions the respondent answered appear in
answersanddisplay.submittedAt— falls back to the dispatch timestamp since the respondent never formally submitted.
Each integration fires at most once per abandoned draft. After delivery, the draft is excluded from future sweeps.
Signing
Each webhook has its own signing secret — set in the UI when you configure a custom webhook, or with the optional
signingSecret parameter (32–255 characters) on webhooks.create. It is not the workspace request signing secret
used for request callbacks, but the header and the algorithm are identical, so one verifier handles
both.
Every signed delivery carries X-formbase-Signature: t={seconds},sha256={hex}. The digest is an
HMAC-SHA256 of {t}.{raw body}, keyed with your secret. Two rules: hash the raw body
before any parsing or re-serializing, and compare in constant time.
import crypto from 'node:crypto'
export function verifyFormbaseWebhook(rawBody: string, header: string | undefined, secret: string, toleranceSeconds = 300): boolean {
if (!header) return false
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2)))
if (!parts.t || !parts.sha256) return false
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(parts.sha256, 'hex')
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return false
return Math.abs(Date.now() / 1000 - Number(parts.t)) <= toleranceSeconds
}import hashlib, hmac, time
def verify_formbase_webhook(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
parts = dict(p.split('=', 1) for p in header.split(',') if '=' in p)
t, received = parts.get('t'), parts.get('sha256')
if not t or not received:
return False
expected = hmac.new(secret.encode(), f'{t}.'.encode() + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, received):
return False
return abs(time.time() - int(t)) <= tolerance_secondsformbase does not enforce a replay window, so the tolerance above is yours to choose. Changing the secret takes effect on the next attempt, including retries already in flight — update your receiver first.
Always verify in production
Without verification, anyone who discovers your URL can post fake submissions.
Retries
formbase makes up to 5 attempts per delivery — 1 initial and 4 retries — at least 1, 2, 4, and 8 minutes apart. formbase looks for due
retries every 30 minutes, so a retry can come up to half an hour after its backoff ends, and the last attempt about two hours after the
first. A Retry-After header on your response is honored when it asks for longer than the next backoff step. A delivery counts
as failed if your endpoint:
- Returns a non-2xx status
- Times out
- Resets the connection
One case is never retried: a destination that is blocked, unresolvable, or resolves to a private address. The URL is revalidated — DNS included — immediately before every attempt, so a host that stops being allowed fails the delivery at once rather than burning the budget.
After 5 consecutive failed deliveries, the integration is paused. Fix the endpoint and re-enable it from Form settings → Integrations; a successful delivery resets the counter.
Retried deliveries of the same event reuse the same id, so deduplicate by storing processed ids. A genuinely new event — a
respondent editing their submission, say — arrives with a fresh id and type: “submission.updated”: at the Form
settings webhook, or at a submission_updated subscription. The createdAt is when the event was queued, not when
the attempt was made, so it stays the same across retries too. To tell edits apart, read data.submission.editCount: it counts
up with each edit, and data.submission.updatedAt says when the latest one happened.
Testing
The integration setup and detail panel both have a Send test button. It fires a sample of the subscribed event to your
URL so you can verify the connection without waiting for a real submission or request. The same samples are available over the API as
submissions.sample and requests.sample.
For local development, expose your dev server with a tunnel:
# ngrok
ngrok http 3000
# cloudflare tunnel
cloudflared tunnel --url http://localhost:3000Local development
Use the tunnel URL as your webhook endpoint, then hit Send test to verify end-to-end.