Go to app

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

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 go to the same URL, so check type before you read answers. An expiry reaches you within 15 minutes of expiresAt.

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" },
    "aiValidations": {
      "company_check": { "status": "passed", "fields": ["company_name"] }
    }
  }
}
  • data.request is always there, with 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. Expired and canceled requests have none.

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

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

  • aiValidations is there when the form has AI Validations. Its fields are described on the webhooks reference.

  • test is true for 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.

Headers

  • Content-Type: application/json and User-Agent: Formstep

  • X-Formstep-Event-Id and X-Formstep-Event-Type, the same values as id and type in the body, so you can deduplicate and route before parsing

  • X-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.

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 responseWhat Formstep does
2xxDone. The callback is marked delivered.
408, 429, 5xxRetries, and honours Retry-After when you send one.
Timeout or connection errorRetries on the same schedule.
Any other 4xxStops. Your endpoint refused the body, and sending it again cannot help.
Blocked URLStops 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:

WebhookRequest callback
Attempts5, over about two hours8, over about four hours
A 408 from your endpointStopsRetried
When attempts run outAfter 5 failed deliveries in a row, the integration pauses until someone clicks ReactivateThe 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.

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

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

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