# Webhook API reference

Payload schema, event types, signing, retries, and REST subscription endpoints. Read it to parse, verify, and dedupe the events Formstep sends to your URL.

> **Looking for the setup guide?**
>
> To set up a custom webhook for your form in the app, see [Custom webhooks](https://formstep.io/docs/integrations/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](https://formstep.io/docs/developers/callbacks/) 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:

| 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](https://formstep.io/docs/developers/callbacks/) 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](https://formstep.io/docs/developers/callbacks/) and a `request_*` subscription use the same envelope with `request.completed`, `request.expired` and `request.canceled`, so one parser reads all six.

## Payload shape

```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"
    }
  }
}
```

| 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](https://formstep.io/docs/building-forms/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:

```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:

```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](https://formstep.io/docs/integrations/webhooks/) added in the form’s Integrations dialog, a submission that answered a [request](https://formstep.io/docs/requests/overview/) 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.

```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](https://formstep.io/docs/developers/callbacks/) 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](https://formstep.io/docs/building-forms/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.

```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](https://formstep.io/docs/building-forms/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.

```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 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 `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](https://formstep.io/docs/developers/callbacks/#verify), 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.

**Node.js**

```javascript
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
}
```

**Python**

```python
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_seconds
```

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.

> **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](https://formstep.io/docs/developers/callbacks/#retries).

- 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

[Webhooks setup](https://formstep.io/docs/integrations/webhooks/) — Configure webhooks for your form

[Plans & pricing](https://formstep.io/docs/subscription-billing/plans-pricing/) — Compare plan API features and limits
