Requests
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.
What fires, and when
| Event | When |
|---|---|
| request.completed | The recipient submitted. Carries the answers. |
| request.expired | The expiry passed while the request was still pending. |
| request.canceled | You or your automation withdrew it. |
All three arrive at the same URL, so branch on type 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.
What arrives
{
"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" }
}
}data.requestis always there — including yourexternalId,metadata, andcontext, unchanged. It carries the timestamp of whichever ending happened (completedAt,expiredAt, orcanceledAtwith an optionalcancelReason).outcomeis present only when the recipient answered a decision question —approve,decline, orchanges.form,submission,answersanddisplayappear on completion only, in exactly the shape a submission webhook carries.form.snapshotIdis the exact published version the recipient answered;submission.pdfUrlis a URL only when the form retains a submission PDF, and null otherwise.testistruewhen the request was created in test mode — branch on it, or drop the event.answersis keyed by field key, with repeating groups nested as one object per instance. A choice answer is the option key fromfields.list, not its label; the label is indisplay, under the same key.The POST arrives as
Content-Type: application/jsonwithUser-Agent: formbase, and carriesX-formbase-Event-Id,X-formbase-Event-TypeandX-formbase-Signature— so you can dedupe and route before parsing.
Deduplicate on id
id 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.
Verify the signature
Every callback carries a signature header, X-formbase-Signature: t={unix seconds},sha256={hex}. The hex
is an HMAC-SHA256 of the timestamp, a dot, and the raw request body, computed with your workspace’s
request signing secret.
Two rules, whatever language you use:
Hash the raw body, before any parsing or re-serializing. Re-encoded JSON is not the same bytes.
Compare in constant time —
crypto.timingSafeEqual,hmac.compare_digest— never with==.
import crypto from 'node:crypto'
export function verifyFormbaseCallback(rawBody, header, secret, toleranceSeconds = 300) {
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_secondsThe request signing secret
One secret per workspace signs every callback from it. Find it on OAuth and API Keys in the workspace sidebar, in the
Request signing secret card. It is masked by default; Reveal secret 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 callbackUrl has none yet.
Regenerating has no grace period
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. Update your receiver first, then regenerate. There is no window where both secrets are accepted.
Retries
A callback gets eight attempts: 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 id: 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.
| Your response | What formbase does |
|---|---|
| 2xx | Done. The callback is marked delivered. |
| 408, 429, 5xx | Retries, honouring Retry-After when you send one. |
| Other 4xx | Stops. Your endpoint rejected the call; retrying the same body cannot help. |
| Timeout or connection error | Retries on the same schedule. |
| Blocked URL | Stops immediately. A host that does not resolve, a private address, or a non-HTTPS URL cannot become allowed. Redirects are never followed, so a 3xx stops too. |
If the budget runs out — your receiver was down for the afternoon — the callback is not lost. The request gets a
Callback failed badge, the workspace owner is emailed once with the host, the reason and the attempt count, and the
answers stay readable through requests.get. To push it again, open the request in the
Requests page and press Replay, or call requests.replayCallback.
It re-sends the same frozen payload with the same id, which is exactly what a receiver that deduplicates wants.
Subscriptions hear the same events
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 webhooks.create does it from code with request_completed,
request_expired or request_canceled 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. Replay re-sends the callback only; a subscription
retries on its own and pauses after five failed attempts.
A resume URL is not authentication
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.