Request callbacks
Give a request a callbackUrl and Formstep sends one signed POST to it when the request ends: completed, expired or canceled. Your workflow picks up from that POST.
Set callbackUrl on requests.create. It must be HTTPS and resolve to a
public address, so localhost does not work; while you test, use a tunnel as in
Receive and verify the callback.
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 go to the same URL, so check type before you read answers. An expiry reaches you within 15 minutes of
expiresAt.
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" },
"aiValidations": {
"company_check": { "status": "passed", "fields": ["company_name"] }
}
}
}data.requestis always there, with yourexternalId,metadataandcontextunchanged. It carries the timestamp of whichever ending happened:completedAt,expiredAt, orcanceledAtwith an optionalcancelReason.outcomeis present only when the recipient answered a decision question:approve,declineorchanges. Expired and canceled requests have none.form,submission,answersanddisplayappear on completion only, in the shape a submission webhook carries.form.snapshotIdis the published version the recipient answered.submission.pdfUrlis a URL only when the form keeps a submission PDF.answersis keyed by field key, with repeating groups nested as one object per item. A choice answer is the option key; its label is indisplayunder the same key.aiValidationsis there when the form has AI Validations. Its fields are described on the webhooks reference.testistruefor a test request. Branch on it, or drop the event.
requests.sample returns this envelope for any form without a real request, which helps when you map fields before the first one ends.
Deduplicate on id
id stays the same across every retry and every replay of the same event. If acting twice would hurt, such as a second
invoice or a second ticket, store the ids you have handled and skip a repeat.
Headers
Content-Type: application/jsonandUser-Agent: FormstepX-Formstep-Event-IdandX-Formstep-Event-Type, the same values asidandtypein the body, so you can deduplicate and route before parsingX-Formstep-Signature: t={unix seconds},sha256={hex}, on every callback
Verify the signature
A callback is signed exactly like a webhook: the same header, an HMAC-SHA256 of the timestamp, a dot and the raw body. Use the verifier on Webhooks reference: Signing. Two things differ:
The key is the workspace's request signing secret, one secret for every callback in the workspace. A webhook uses its own secret.
- Every callback is signed. A webhook is signed only when it has a secret.
To find the secret, open OAuth and API Keys in the workspace sidebar and look for the Request signing secret card. Only the workspace owner sees it: click Reveal secret, then copy it. The owner can read it again at any time. The secret is never returned over the API or MCP, which is why an agent with nobody to set up a receiver should poll instead.
Regenerating has no grace period
When the owner regenerates the secret, the old one stops working at once, even for callbacks being retried. Update your receiver first, then regenerate.
When a valid callback fails the check, the cause is almost always the body. Parsing the JSON and serializing it again changes the bytes, so hash the body exactly as it arrived. The other common cause is a regenerated secret the receiver has not picked up yet.
A resume URL is not authentication
A workflow tool's hard-to-guess resume URL can leak into logs, and it does not prove who sent the body. Verify the signature anyway.
Retries
Answer with a 2xx within 10 seconds. A callback gets eight attempts over about four hours, and every attempt sends the same body with the
same id, as it was when the request ended.
| Your response | What Formstep does |
|---|---|
| 2xx | Done. The callback is marked delivered. |
| 408, 429, 5xx | Retries, and honours Retry-After when you send one. |
| Timeout or connection error | Retries on the same schedule. |
| Any other 4xx | Stops. Your endpoint refused the body, and sending it again cannot help. |
| Blocked URL | Stops at once. 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. |
A callback gets a longer budget than a webhook, because a workflow run is waiting on it:
| Webhook | Request callback | |
|---|---|---|
| Attempts | 5, over about two hours | 8, over about four hours |
| A 408 from your endpoint | Stops | Retried |
| When attempts run out | After 5 failed deliveries in a row, the integration pauses until someone clicks Reactivate | The request gets a Callback failed badge and the owner one email; nothing pauses |
When a callback never lands
The request page shows the callback URL and what happened to it. requests.get carries callbackFailedAt while the
callback has run out of attempts. The answers are not lost: requests.get still returns them.
Check the URL. A workflow tool's resume URL belongs to one run, and a run that was deleted or re-created no longer answers on it.
Check what your endpoint returned. A 4xx other than 408 or 429 stops the retries at once, so a receiver that rejects a valid body never gets a second try.
Fix the receiver, then send the callback again: Replay on the request in the Requests page, or requests.replayCallback. A replay sends the same body with the same
id, so a receiver that deduplicates is safe.
Subscriptions hear the same events
A callback URL belongs to one request. To send every request of a form to one receiver, subscribe once instead: the Zapier, Make and n8n
apps do this for you, and from code you call webhooks.create with
request_completed, request_expired or request_canceled. A subscription gets the same body, signed
with its own secret, under its own event id, with the webhook retry budget. A request with both a callback URL and a subscription sends to
both. Test requests reach no subscription, and Replay resends only the callback.