Go to app

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/json
  • X-Formstep-Signature: t={timestamp},sha256={hex}, present when a signing secret is set up (see below)

  • X-Formstep-Event-Id and X-Formstep-Event-Type, which carry the same values as id and type in 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 through webhooks.create have no custom headers.

Event types

Subscription events

When you set up a webhook integration, you choose which event triggers deliveries:

EventWhen it fires
submission_createdA respondent completes and submits the form. This is the default.
submission_updatedA respondent edits a submission they already sent, when the form allows editing after submit.
submission_abandonedA draft submission has been idle past the configured window. Requires partial submission tracking (Pro and Business).
request_completedA recipient completes a request on the form. Carries the request block and the answers.
request_expiredA request on the form expires before the recipient completes it. Request block only.
request_canceledA 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 submission

  • submission.updated: an existing submission was edited

  • submission.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

POST body
json
{
  "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"
    }
  }
}
KeyWhat it is
idThe event id. Retries reuse it, so deduplicate on it.
typeOne of the six event types above.
createdAtWhen the event was queued, not when this delivery attempt was made. Stable across retries.
apiVersionThe payload contract, as a date. It changes when a key is removed, renamed or changes meaning. New keys arrive without a change.
testtrue for a sample or test delivery, and for a request created in test mode. Always present.
data.form.snapshotIdThe published version the respondent answered. Field keys, titles and types are fixed per snapshot.
data.submission.updatedAtWhen the respondent last edited the submission, or null until the first edit.
data.submission.editCountHow many times the respondent edited the submission after sending it: 0 on submission.completed, 1 on the first edit.
data.answersEvery answer, keyed by field key. Each answer appears once.
data.displayHuman-readable text for every answer, under the same keys.
data.schemaOptional: 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.aiValidationsPresent 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:

data.answers
json
{
  "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:

data.schema
json
[
  { "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.

Added to data
json
"request": {
  "id": "kd7...",
  "externalId": "run-42",
  "metadata": { "crm_record": "rec_88" }
}
  • id: the request this submission answered. Pass it to requests.get for the full picture.

  • externalId and metadata: your own bookkeeping, exactly as you supplied it on requests.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.

data.aiValidations
json
{
  "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.

data.answers
json
{
  "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 windowDescription
12 hoursFor same-day follow-ups
1 dayA reasonable gap before nudging
3 daysFor less urgent forms
1 weekFor 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 answers and display.

  • 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. 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.

Formstep 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.

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:

bash
# Cloudflare quick tunnel, no account needed
cloudflared tunnel --url http://localhost:3000

# or ngrok

ngrok http 3000

Local development

Use the tunnel URL as the webhook URL and click Send test, or pass it as a request's callbackUrl.

Next steps