# Callbacks & signing

Resume your workflow when a request ends, and prove the call really came from formbase.

## Callbacks & signing

When a request reaches its end — completed, expired, or canceled — formbase POSTs a signed notification to the URL your automation gave it. That call is what resumes the run.

<h2 id="what-fires">What fires, and when</h2>

<p>
  All three arrive at the same URL, so branch on <code>type</code> before assuming there are answers. That is the whole point of firing on
  every ending: a workflow parked on a customer resumes whether they answered, ignored you, or were called off.
</p>

<h2 id="payload">What arrives</h2>

```
{
  "id": "evt_kj7...",
  "type": "request.completed",
  "createdAt": "2026-03-04T09:31:40.000Z",
  "apiVersion": "2026-09-24",
  "test": false,
  "data": {
    "request": {
      "id": "kd7...",
      "externalId": "run-42",
      "status": "completed",
      "outcome": "approve",
      "language": "en",
      "recipient": { "email": "ada@acme.com", "name": "Ada" },
      "metadata": { "runId": "run-42" },
      "context": { "case_id": "CASE-9" },
      "createdAt": "2026-03-04T09:20:00.000Z",
      "completedAt": "2026-03-04T09:31:40.000Z"
    },
    "form": { "id": "j57...", "name": "Vendor onboarding", "snapshotId": "kx2..." },
    "submission": {
      "id": "jd7...",
      "respondentEmail": "ada@acme.com",
      "submittedAt": "2026-03-04T09:31:40.000Z",
      "updatedAt": null,
      "editCount": 0,
      "pdfUrl": null,
      "language": "en"
    },
    "answers": { "company_name": "Acme", "contacts": [{ "name": "Ada" }] },
    "display": { "company_name": "Acme", "contacts": "Ada" }
  }
}
```

<ul>
  <li>
    <code>data.request</code> is always there — including your <code>externalId</code>, <code>metadata</code>, and <code>context</code>,
    unchanged. It carries the timestamp of whichever ending happened (<code>completedAt</code>, <code>expiredAt</code>, or{' '}
    <code>canceledAt</code> with an optional <code>cancelReason</code>).
  </li>
  <li>
    <code>outcome</code> is present only when the recipient answered a <a href="/requests/decisions-and-approvals">decision question</a> —{' '}
    <code>approve</code>, <code>decline</code>, or <code>changes</code>.
  </li>
  <li>
    <code>form</code>, <code>submission</code>, <code>answers</code> and <code>display</code> appear on completion only, in exactly the
    shape a <a href="/developers/webhooks-reference#payload">submission webhook</a> carries. <code>form.snapshotId</code> is the exact
    published version the recipient answered; <code>submission.pdfUrl</code> is a URL only when the form retains a submission PDF, and null
    otherwise.
  </li>
  <li>
    <code>test</code> is <code>true</code> when the request was created in <a href="/requests/creating-requests#test-mode">test mode</a> —
    branch on it, or drop the event.
  </li>
  <li>
    <code>answers</code> is keyed by <a href="/requests/field-keys">field key</a>, with repeating groups nested as one object per instance.
    A choice answer is the option <strong>key</strong> from <code>fields.list</code>, not its label; the label is in <code>display</code>,
    under the same key.
  </li>
  <li>
    The POST arrives as <code>Content-Type: application/json</code> with <code>User-Agent: formbase</code>, and carries{' '}
    <code>X-formbase-Event-Id</code>, <code>X-formbase-Event-Type</code> and <code>X-formbase-Signature</code> — so you can dedupe and route
    before parsing.
  </li>
</ul>

> ⚠️ **Deduplicate on id**
> <p>
>     <code>id</code> is stable across every retry and every replay of the same event. If your receiver might act twice on the same id — a
>     duplicate invoice, a duplicate ticket — remember the ids you have already handled.
>   </p>

<h2 id="verify">Verify the signature</h2>

<p>
  Every callback carries a signature header, <code>X-formbase-Signature: t=&#123;unix seconds&#125;,sha256=&#123;hex&#125;</code>. The hex
  is an HMAC-SHA256 of the timestamp, a dot, and the raw request body, computed with your workspace's{' '}
  <strong>request signing secret</strong>.
</p>

<p>Two rules, whatever language you use:</p>

<ol>
  <li>
    Hash the <strong>raw</strong> body, before any parsing or re-serializing. Re-encoded JSON is not the same bytes.
  </li>
  <li>
    Compare in constant time — <code>crypto.timingSafeEqual</code>, <code>hmac.compare_digest</code> — never with <code>==</code>.
  </li>
</ol>

  
    
```
import crypto from 'node:crypto'

  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
  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_callback(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
```

  

<h2 id="secret">The request signing secret</h2>

<p>
  One secret per workspace signs every callback from it. Find it on <strong>OAuth and API Keys</strong> in the workspace sidebar, in the{' '}
  <strong>Request signing secret</strong> card. It is masked by default; <strong>Reveal secret</strong> shows it and the copy button copies
  it. It is not a one-time value — you can come back and read it again. The secret is minted the first time it is needed, so a workspace
  that has never opened that card and never created a request with a <code>callbackUrl</code> has none yet.
</p>

> ❗ **Regenerating has no grace period**
> <p>
>     Only the workspace owner can regenerate the secret, and the moment they do, the old one stops working — including for callbacks that are
>     already being retried. <strong>Update your receiver first, then regenerate.</strong> There is no window where both secrets are accepted.
>   </p>

<h2 id="retries">Retries</h2>

<p>
  A callback gets <strong>eight attempts</strong>: the first, then seven retries at least 1, 2, 4, 8, 16, 32 and 60 minutes apart. formbase
  looks for due retries every 30 minutes, so a retry can come up to half an hour after its gap ends, and the last attempt comes about four
  hours after the request ended. Every attempt carries the same bytes and the same <code>id</code>: the payload is frozen at the moment the
  request ended, so a retry describes what happened then, not what the request looks like now. The destination URL and the signing secret
  are read at each attempt, not frozen with it.
</p>

<p>
  If the budget runs out — your receiver was down for the afternoon — the callback is not lost. The request gets a{' '}
  <strong>Callback failed</strong> badge, the workspace owner is emailed once with the host, the reason and the attempt count, and the
  answers stay readable through <code>requests.get</code>. To push it again, open the request in the{' '}
  <a href="/requests/managing-requests">Requests page</a> and press <strong>Replay</strong>, or call <code>requests.replayCallback</code>.
  It re-sends the same frozen payload with the same <code>id</code>, which is exactly what a receiver that deduplicates wants.
</p>

<h2 id="subscriptions">Subscriptions hear the same events</h2>

<p>
  A callback URL belongs to one request. When every request on a form should reach the same receiver, subscribe once instead: the formbase
  apps for Zapier and n8n do this for you, and <code>webhooks.create</code> does it from code with <code>request_completed</code>,{' '}
  <code>request_expired</code> or <code>request_canceled</code> as the event type. A subscription receives this same envelope, signed with
  its own secret rather than the workspace request signing secret, with its own event id and its own retry budget. A request that has both a
  callback URL and a matching subscription fires twice, once to each. <strong>Replay</strong> re-sends the callback only; a subscription
  retries on its own and pauses after five failed attempts.
</p>

> 💡 **A resume URL is not authentication**
> <p>
>     Workflow tools hand you a hard-to-guess resume URL and it is tempting to treat that as proof. It is a bearer secret — it can leak into
>     logs, and it does not tell you the body was not tampered with. Verify the signature in the resumed branch as well.
>   </p>

<div class="not-prose grid gap-3 sm:grid-cols-2">
  - [Troubleshooting](/requests/troubleshooting) — When a callback keeps failing.
  - [Webhook reference](/developers/webhooks-reference) — The answers and display maps a completion carries, in full.
</div>
