# Webhook API reference

Payload schema, event types, signing, retries, and REST subscription endpoints.

## Webhook API reference

Payload schema, event types, signing, retry behavior, and the REST endpoints any automation tool subscribes through.

> ℹ️ **Looking for the setup guide?**
> <p>
>     This page documents the payload contract and REST subscription API. To set up a custom webhook for your form in the UI, see{' '}
>     <a href="/integrations/webhooks">Custom webhooks</a>.
>   </p>

<h2 id="request">Request</h2>
<p>
  <code>POST &lt;your-url&gt;</code> with <code>Content-Type: application/json</code>.
</p>

<h3 id="headers">Headers</h3>
<ul>
  <li>
    <code>Content-Type: application/json</code>
  </li>
  <li>
    <code>X-formbase-Signature: t=&#123;timestamp&#125;,sha256=&#123;hex&#125;</code> — present when a signing secret is set up (see below)
  </li>
  <li>
    <code>X-formbase-Event-Id</code> and <code>X-formbase-Event-Type</code> — the same values as <code>id</code> and <code>type</code> in
    the body, so you can dedupe and route before parsing. A <a href="/requests/callbacks">request callback</a> sends the same two.
  </li>
  <li>
    Any custom headers you add during setup. They are merged in as given, except <code>Content-Type</code>, which cannot be overridden.
    Native subscriptions created through <code>webhooks.create</code> have no custom headers.
  </li>
</ul>

<h2 id="event-types">Event types</h2>

<h3 id="subscription-events">Subscription events</h3>
<p>When you set up a webhook integration, you choose which event triggers deliveries:</p>

<p>
  The three <code>request_*</code> events are subscribed through <code>webhooks.create</code> and are what the formbase apps for Zapier,
  Make and n8n listen on. Each delivers the same envelope a <a href="/requests/callbacks">request callback</a> sends, signed with the
  subscription's own secret. Test requests never reach a subscription, and <code>requests.replayCallback</code> re-sends the callback only.
  One subscription, one event: <code>submission_created</code> is share-link traffic and <code>request_completed</code> is request traffic,
  so a completed request never fires <code>submission_created</code> and a form with both subscriptions receives one delivery per
  completion. An edit reaches a <code>submission_updated</code> subscription alone, never a <code>submission_created</code> one. The webhook
  you configure in Form settings has no event choice: it receives first submissions and edits alike, told apart by <code>type</code>.
</p>

<h3 id="payload-event-types">Payload event types</h3>
<p>
  The <code>type</code> field in the JSON body tells you what happened:
</p>
<ul>
  <li>
    <code>submission.completed</code> — a new completed submission
  </li>
  <li>
    <code>submission.updated</code> — an existing submission was edited
  </li>
  <li>
    <code>submission.abandoned</code> — a draft submission was abandoned after the configured idle window
  </li>
</ul>
<p>
  A <a href="/requests/callbacks">request callback</a> and a <code>request_*</code> subscription use the same envelope with{' '}
  <code>request.completed</code>, <code>request.expired</code> and <code>request.canceled</code>, so one parser reads all six.
</p>

<h2 id="payload">Payload shape</h2>

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

<h3 id="fields-vs-answers">answers and display</h3>
<p>
  <code>answers</code> is the flat <code>{'{ field key: value }'}</code> object, keyed by the field keys frozen at publish. Read it when a
  workflow branches or stores a value: <code>answers.email</code>, no array to walk. A choice answer is the chosen option's{' '}
  <strong>key</strong> — the <code>key</code> that <code>fields.list</code> 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 <code>null</code>.
</p>
<p>
  <code>display</code> 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.
</p>
<p>
  A repeating group appears in <code>answers</code> once, under the group's own field key, as an array of row objects keyed by each member's
  field key — <code>answers.attendees[0].attendee_name</code> above — and in <code>display</code> as one line with the rows joined. A member
  is never hoisted to the top level. <a href="/building-forms/calculated-fields">Calculated fields</a> appear in both maps under the
  calculated field's name as its key (<code>answers.total</code>).
</p>
<h3 id="bookings-and-payments">Bookings and payments</h3>
<p>
  A Schedule appointment question and a Payment question each carry an object in <code>answers</code>, under the question's field key, and
  one line of text in <code>display</code>. The times are ISO instants, so a spreadsheet or a workflow can parse them whatever language the
  respondent answered in:
</p>

```
{
  "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_..."
  }
}
```

<p>
  A booking's <code>status</code> is <code>confirmed</code>, <code>rescheduled</code>, <code>cancelled</code>, <code>rejected</code> or{' '}
  <code>no_show</code>. A payment's is <code>paid</code>, <code>partially_refunded</code>, <code>refunded</code> or <code>disputed</code>,
  and <code>amount</code> is in the currency's main unit: <code>40</code> is $40.00. When Cal.com moves a booking or Stripe refunds a
  payment after the submission, formbase updates the object, so <code>submissions.list</code> and later events show the current state; no
  new event is sent for the change.
</p>
<p>
  Before <code>apiVersion</code> <code>2026-09-24</code>, a booking was sent as one sentence in <code>answers</code> and a payment was not
  sent at all.
</p>

<p>
  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 <code>answers</code> and <code>display</code> 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.
</p>

<h3 id="schema">Field titles and types</h3>
<p>
  The event does not repeat each field's title and type. Read them from <code>fields.list</code>, which is stable per{' '}
  <code>data.form.snapshotId</code>, 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 <strong>Send the field list with every event</strong> in the webhook's settings; the event then carries{' '}
  <code>data.schema</code>, one entry per field. A choice question lists its <code>options</code> and a matrix its <code>rows</code> and{' '}
  <code>columns</code>, each as <code>{'{ key, label }'}</code>, so the keys in <code>answers</code> resolve to labels without a second
  call:
</p>

```
[
  { "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" }
]
```

<h3 id="request-block">The request block</h3>
<p>
  On the <a href="/integrations/webhooks">custom webhook</a> configured in form settings, a submission that answered a{' '}
  <a href="/requests/overview">request</a> carries one extra object inside <code>data</code>, <code>request</code>. 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 <code>request.completed</code>, which carries the full request block.
</p>

```
"request": {
  "id": "kd7...",
  "externalId": "run-42",
  "metadata": { "crm_record": "rec_88" }
}
```

<ul>
  <li>
    <code>id</code> — the request this submission answered. Pass it to <code>requests.get</code> for the full picture.
  </li>
  <li>
    <code>externalId</code> and <code>metadata</code> — your own bookkeeping, exactly as you supplied it on <code>requests.create</code>.
    Each is present only when it was set.
  </li>
</ul>

> ℹ️ **Webhooks are not callbacks**
> <p>
>     A submission webhook fires on a submission; the request block only names the request it answered. A{' '}
>     <a href="/requests/callbacks">callback</a> fires when a request ends — completed, expired, or canceled — and carries{' '}
>     <code>context</code> and <code>outcome</code>. 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 <code>request_completed</code>, <code>request_expired</code> or{' '}
>     <code>request_canceled</code> through <code>webhooks.create</code>.
>   </p>

<h3 id="submission-pdf">Submission PDF</h3>
<p>
  <code>data.submission.pdfUrl</code> 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 <code>null</code> only when the submission has no PDF.
</p>

<h3 id="submission-language">Submission language</h3>
<p>
  <code>data.submission.language</code> is the BCP-47 code of the language the respondent submitted in (for translated forms). It is{' '}
  <code>null</code> for single-language forms. Use it to route or branch on the respondent's language without a separate lookup.
</p>

<h2 id="abandoned-submissions">Abandoned submission payloads</h2>
<p>
  When you subscribe to <code>submission_abandoned</code>, formbase checks for idle drafts every hour. If a draft has been inactive past the
  configured idle window, formbase fires a delivery.
</p>
<p>
  Native API integrations must send <code>idleWindow</code> to <code>webhooks.create</code>. Accepted values are <code>12h</code>,{' '}
  <code>1d</code>, <code>3d</code>, and <code>1w</code>. There is no implicit default; an abandoned subscription without a value is
  rejected.
</p>

<p>The payload shape is identical to a completed submission. Two differences:</p>
<ul>
  <li>
    <strong>Answers may be sparse</strong> — only questions the respondent answered appear in <code>answers</code> and <code>display</code>.
  </li>
  <li>
    <strong>
      <code>submittedAt</code>
    </strong>{' '}
    — falls back to the dispatch timestamp since the respondent never formally submitted.
  </li>
</ul>
<p>Each integration fires at most once per abandoned draft. After delivery, the draft is excluded from future sweeps.</p>

<h2 id="signing">Signing</h2>
<p>
  Each webhook has its own signing secret — set in the UI when you configure a custom webhook, or with the optional{' '}
  <code>signingSecret</code> parameter (32–255 characters) on <code>webhooks.create</code>. It is not the workspace request signing secret
  used for <a href="/requests/callbacks">request callbacks</a>, but the header and the algorithm are identical, so one verifier handles
  both.
</p>
<p>
  Every signed delivery carries <code>X-formbase-Signature: t=&#123;seconds&#125;,sha256=&#123;hex&#125;</code>. The digest is an
  HMAC-SHA256 of <code>&#123;t&#125;.&#123;raw body&#125;</code>, keyed with your secret. Two rules: hash the <strong>raw</strong> body
  before any parsing or re-serializing, and compare in constant time.
</p>

```
import crypto from 'node:crypto'

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

<p>
  formbase 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.
</p>

> ⚠️ **Always verify in production**
> <p>Without verification, anyone who discovers your URL can post fake submissions.</p>

<h2 id="retries">Retries</h2>
<p>
  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 <code>Retry-After</code> header on your response is honored when it asks for longer than the next backoff step. A delivery counts
  as failed if your endpoint:
</p>
<ul>
  <li>Returns a non-2xx status</li>
  <li>Times out</li>
  <li>Resets the connection</li>
</ul>
<p>
  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.
</p>
<p>
  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.
</p>
<p>
  Retried deliveries of the same event reuse the same <code>id</code>, so deduplicate by storing processed ids. A genuinely new event — a
  respondent editing their submission, say — arrives with a fresh <code>id</code> and <code>type: "submission.updated"</code>: at the Form
  settings webhook, or at a <code>submission_updated</code> subscription. The <code>createdAt</code> 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 <code>data.submission.editCount</code>: it counts
  up with each edit, and <code>data.submission.updatedAt</code> says when the latest one happened.
</p>

<h2 id="testing">Testing</h2>
<p>
  The integration setup and detail panel both have a <strong>Send test</strong> 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{' '}
  <code>submissions.sample</code> and <code>requests.sample</code>.
</p>
<p>For local development, expose your dev server with a tunnel:</p>

```
# ngrok
ngrok http 3000

# cloudflare tunnel

cloudflared tunnel --url http://localhost:3000
```

> 💡 **Local development**
> <p>Use the tunnel URL as your webhook endpoint, then hit Send test to verify end-to-end.</p>

<h2 id="next-steps">Next steps</h2>
<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Webhooks setup](/integrations/webhooks) — Configure webhooks for your form
  - [Plans & pricing](/subscription-billing/plans-pricing) — Compare plan API features and limits
</div>
