formbasedocs
Go to appApp

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

EventWhen
request.completedThe recipient submitted. Carries the answers.
request.expiredThe expiry passed while the request was still pending.
request.canceledYou 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

POST body
json
{
  "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.request is always there — including your externalId, metadata, and context, unchanged. It carries the timestamp of whichever ending happened (completedAt, expiredAt, or canceledAt with an optional cancelReason).

  • outcome is present only when the recipient answered a decision question — approve, decline, or changes.

  • form, submission, answers and display appear on completion only, in exactly the shape a submission webhook carries. form.snapshotId is the exact published version the recipient answered; submission.pdfUrl is a URL only when the form retains a submission PDF, and null otherwise.

  • test is true when the request was created in test mode — branch on it, or drop the event.

  • answers is keyed by field key, with repeating groups nested as one object per instance. A choice answer is the option key from fields.list, not its label; the label is in display, under the same key.

  • The POST arrives as Content-Type: application/json with User-Agent: formbase, and carries X-formbase-Event-Id, X-formbase-Event-Type and X-formbase-Signature — so you can dedupe and route before parsing.

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:

  1. Hash the raw body, before any parsing or re-serializing. Re-encoded JSON is not the same bytes.

  2. Compare in constant time — crypto.timingSafeEqual, hmac.compare_digest — never with ==.

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

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 responseWhat formbase does
2xxDone. The callback is marked delivered.
408, 429, 5xxRetries, honouring Retry-After when you send one.
Other 4xxStops. Your endpoint rejected the call; retrying the same body cannot help.
Timeout or connection errorRetries on the same schedule.
Blocked URLStops 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.