Webhook API reference
Formstep POSTs a JSON event to your URL when someone submits, edits or abandons a form, or when a request on it ends. Parse it, verify its signature, and subscribe from code.
Looking for the setup guide?
To set up a custom webhook for your form in the app, see Custom webhooks.
Request
POST <your-url> with Content-Type: application/json. The URL must use HTTPS, carry no credentials, be at
most 2,048 characters, and resolve to a public address. The address is checked again, DNS included, before every delivery.
Headers
Content-Type: application/jsonX-Formstep-Signature: t={timestamp},sha256={hex}, present when a signing secret is set up (see below)X-Formstep-Event-IdandX-Formstep-Event-Type, which carry the same values asidandtypein the body, so you can dedupe and route before parsing. A request callback sends the same two.Up to 5 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 and Business). |
| 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 Formstep 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.
Each subscription listens to 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 add in the form’s Integrations dialog 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, so 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. |
| data.aiValidations | Present when the form has AI Validations: one entry per check under its key, with status (passed, flagged or not_checked), the fields it read by field key, a reason when it flagged the answers, and a notCheckedReason (no_plan or no_credits) when the plan or the AI credits kept it from running. See AI Validation. |
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. 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, for example in a Slack message, a spreadsheet cell or 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, Formstep 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 added in the form’s Integrations dialog, 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.
AI Validation results
Each AI Validation check reports under its own key in data.aiValidations. A check
is keyed ai_validation, ai_validation_2 and so on, until someone gives it a name in the editor's Fields
tab. Callbacks, requests.get and the MCP tool formSubmission_list carry the same object.
{
"website_check": { "status": "passed", "fields": ["company_website"] },
"email_domain": {
"status": "flagged",
"reason": "The work email uses gmail.com, not the company's domain maersk.com.",
"fields": ["work_email", "company_website"]
}
}Branch on status: send a flagged submission to a person to review, or send the recipient a new request for the missing
document.
Documents blocks
A Documents block records which files the respondent was shown, under the block's field
key. source is form for a file the author uploaded and request for one a request added. A checkbox
that confirms reading them answers under its own key.
{
"documents": [
{ "name": "Price list 2026", "source": "form" },
{ "name": "Your lease contract", "source": "request" }
],
"contract_read": true
}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 when the submission has no PDF, or when the PDF
could not be rendered.
File URLs
pdfUrl and every file answer are /api/storage/ URLs. The key in the path is random and unguessable, and anyone
who holds the URL can download the file for as long as it exists: there is no sign-in, and the URL itself does not expire (each visit
redirects to a signed download that lasts an hour). Treat these URLs as part of the submission data: store them where only your systems
read them, keep them out of logs and tickets, and do not forward them by email or chat. Formstep follows the same rule: its notification
emails attach the PDF rather than link to it. Deleting the submission deletes the file, and the URL then answers 404.
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, Formstep checks for idle drafts every hour. If a draft has been inactive past the
configured idle window, Formstep 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 has the same shape as a completed submission, with two differences:
Answers may be sparse: only questions the respondent answered appear in
answersanddisplay.submittedAtfalls 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. A custom webhook gets a whsec_ secret when you set it up, which only the workspace
owner can reveal later. A subscription gets one from the optional signingSecret parameter (32 to 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 the verifier below
handles both.
Every signed delivery carries X-Formstep-Signature: t={seconds},sha256={hex}. The digest is an
HMAC-SHA256 of {t}.{raw body}, keyed with your secret. Hash the raw body before any parsing or
re-serializing, and compare in constant time.
import crypto from 'node:crypto'
export function verifyFormstepSignature(rawBody, header, secret, toleranceSeconds = 300) {
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_formstep_signature(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
parts = dict(p.split('=', 1) for p in (header or '').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_secondsFormstep 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
Answer with a 2xx within 10 seconds. A delivery gets up to 5 attempts, at least 1, 2, 4 and 8 minutes apart. Formstep looks for due
retries every 30 minutes, so the last attempt comes about two hours after the first. A Retry-After header is honoured when it
asks for longer than the next step.
- A 429, a 5xx, a timeout or a reset connection is retried.
Any other 4xx stops at once: your endpoint refused the body, and sending it again cannot help. A 408 stops too, unlike for a request callback.
A destination that is blocked, does not resolve, or resolves to a private address is never retried. The URL is checked again, DNS included, before every attempt.
A 401, 403 or 404 pauses the integration at once and emails the person who set it up. After 5 failed deliveries in a row, the integration pauses too. Fix the endpoint, then open Integrations on the form, pick the webhook under On this form, and click Reactivate. A successful delivery resets the count.
Every attempt of one event carries the same id and createdAt, so deduplicate on id. An edit by the
respondent is a separate event with its own id and type: "submission.updated", sent to the form's
custom webhook or a submission_updated subscription. data.submission.editCount counts the edits, 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. A sample answer is made up per type, such as “John
Doe” for text, 42 for a number and john@example.com for an email, and it is signed and carries your custom headers. The same samples are
available over the API as submissions.sample and requests.sample.
Formstep only delivers to public HTTPS addresses, so localhost does not work. While you develop, give your machine a public
address with a tunnel:
# Cloudflare quick tunnel, no account needed
cloudflared tunnel --url http://localhost:3000
# or ngrok
ngrok http 3000Local development
Use the tunnel URL as the webhook URL and click Send test, or pass it as a request's callbackUrl.