# Request callbacks

Formstep POSTs a signed event to the callbackUrl of a request when it is completed, expires or is canceled. Read the payload, verify the signature, and handle retries and replays.

Set `callbackUrl` on [requests.create](https://formstep.io/docs/developers/rest-api/#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](https://formstep.io/docs/developers/quickstart-receive-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

```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](https://formstep.io/docs/developers/requests-api/#outcome): `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](https://formstep.io/docs/developers/webhooks-reference/#payload) 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](https://formstep.io/docs/developers/requests-api/#field-keys), 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](https://formstep.io/docs/building-forms/ai-validation/). Its fields are described on the [webhooks reference](https://formstep.io/docs/developers/webhooks-reference/#payload).

- `test` is `true` for a [test request](https://formstep.io/docs/developers/requests-api/#test-mode). Branch on it, or drop the event.

[requests.sample](https://formstep.io/docs/developers/rest-api/#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/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](https://formstep.io/docs/developers/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](https://formstep.io/docs/developers/mcp-server/#requests-callbacks).

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

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](https://formstep.io/docs/requests/managing-requests/), or [requests.replayCallback](https://formstep.io/docs/developers/rest-api/#requests-replay-callback). 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](https://formstep.io/docs/developers/rest-api/#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.

[Receive and verify the callback](https://formstep.io/docs/developers/quickstart-receive-the-callback/) — Run a receiver on your machine and watch a callback arrive.

[Webhook API reference](https://formstep.io/docs/developers/webhooks-reference/) — The answers and display maps, and the verifier.
