# Receive and verify the callback

Run a small receiver in Node or Python, check each callback's signature with your signing secret, and read the answers Formstep pushes when a request ends.

You need an API token and a published form, as in [Send a request with the REST API](https://formstep.io/docs/developers/quickstart-send-a-request/), and Node or Python on your machine. What a callback carries, and how retries work, is in [Request callbacks](https://formstep.io/docs/developers/callbacks/).

## 1. Copy the signing secret

Formstep signs every callback with your workspace’s request signing secret. Open **OAuth and API Keys** in the workspace sidebar and find the **Request signing secret** card:

![The Request signing secret card: Workspace secret, masked as rqs\_ followed by asterisks, with buttons to reveal, copy and regenerate it](https://formstep.io/docs/guides/rest-api/signing-secret.png)

Click the copy button and keep the secret in an environment variable in the terminal where your receiver will run:

```bash
export FORMSTEP_SIGNING_SECRET='rqs_...'
```

> **Do not regenerate it**
>
> The third button makes a new secret, and the old one stops working at once, even for callbacks already on their way. You do not need it to follow this guide.

## 2. Write the receiver

Save the verify function from [Signing](https://formstep.io/docs/developers/webhooks-reference/#signing) next to your receiver: as `verify.mjs` for Node, since it uses `import`, or as `verify.py` for Python. The receiver reads the raw body, checks the signature, and only then parses the JSON:

**Node.js**

```javascript
import http from 'node:http'
import { verifyFormstepSignature } from './verify.mjs'
const secret = process.env.FORMSTEP_SIGNING_SECRET
http
  .createServer((req, res) => {
    const chunks = []
    req.on('data', (chunk) => chunks.push(chunk))
    req.on('end', () => {
      const rawBody = Buffer.concat(chunks).toString('utf8')
      const signature = req.headers['x-formstep-signature'] ?? ''
      if (!verifyFormstepSignature(rawBody, signature, secret)) {
        console.log('rejected: bad signature')
        res.writeHead(401).end()
        return
      }
      const event = JSON.parse(rawBody)
      console.log(event.type, event.data.request.externalId, JSON.stringify(event.data.answers))
      res.writeHead(200).end()
    })
  })
  .listen(8787)
```

**Python**

```python
import json, os
from http.server import BaseHTTPRequestHandler, HTTPServer
from verify import verify_formstep_signature
SECRET = os.environ['FORMSTEP_SIGNING_SECRET']
class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        raw_body = self.rfile.read(int(self.headers['Content-Length']))
        signature = self.headers.get('X-Formstep-Signature', '')
        if not verify_formstep_signature(raw_body, signature, SECRET):
            print('rejected: bad signature', flush=True)
            self.send_response(401)
            self.end_headers()
            return
        event = json.loads(raw_body)
        print(event['type'], event['data']['request'].get('externalId'), event['data'].get('answers'), flush=True)
        self.send_response(200)
        self.end_headers()
HTTPServer(('', 8787), Handler).serve_forever()
```

Hash the body exactly as it arrived. Parsing it and turning it back into JSON changes the bytes, and the signature no longer matches.

## 3. Run it and give it a public URL

Start the receiver:

```bash
node receiver.mjs      # or: python3 receiver.py
```

Formstep only calls public HTTPS addresses, so `localhost` will not do. While you test, a tunnel gives your machine one.

[Cloudflare’s quick tunnel](https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/)

needs no account. Run it in a second terminal:

```bash
cloudflared tunnel --url http://localhost:8787
```

It prints an address like `https://horn-cod-classics-arab.trycloudflare.com`. ngrok and similar tools work the same way. In production, use your server’s own HTTPS URL instead.

## 4. Send a request with a callback URL

Create a test request as in the [previous quickstart](https://formstep.io/docs/developers/quickstart-send-a-request/#create), and add `callbackUrl` with the tunnel address. Use your own form ID:

```bash
curl -X POST https://api.formstep.io/api/v1 \
  -H "Authorization: Bearer $FORMSTEP_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "method": "requests.create",
    "params": {
      "formId": "jx75hdx8vb5hy1x85gm17nqgkn8f674g",
      "recipient": { "email": "ada@acme.example", "name": "Ada Lovelace" },
      "prefill": { "company_name": "Analytical Engines Ltd" },
      "readonly": ["company_name"],
      "externalId": "supplier-2044",
      "idempotencyKey": "supplier-2044",
      "callbackUrl": "https://horn-cod-classics-arab.trycloudflare.com/formstep",
      "test": true
    }
  }'
```

Use a new `idempotencyKey`: the same key with a different body is rejected. Open the `url` from the reply, answer the form, and submit.

## 5. See the callback arrive

A few seconds after you submit, the receiver prints the event type, your external ID and the answers. The Node receiver prints:

```text
request.completed supplier-2044 {"certificate_of_incorporation":[{"name":"certificate-of-incorporation.pdf","size":635,"type":"application/pdf","url":"https://api.formstep.io/api/storage/...",...}],"company_name":"Analytical Engines Ltd","contact_email":"ada@acme.example","do_you_accept_our_30_day_payment_terms":"yes","vat_number":"GB123456789"}
```

To see a rejection, send the receiver any POST yourself, for example `curl -X POST -d '{}' http://localhost:8787`. It has no valid signature, so the receiver prints `rejected: bad signature` and answers 401.

## Before you go live

- Answer with a 2xx within 10 seconds. A 5xx, a 408, a 429 or a timeout is retried for about four hours, and any other 4xx, like the 401 above, stops the retries. See [Retries](https://formstep.io/docs/developers/callbacks/#retries).

- Branch on `type`. The same URL also hears `request.expired` and `request.canceled`, which carry no answers.

- Handle each event once. A retry carries the same `id`, so store the ids you have handled and skip a repeat.

- Check `test`. A callback from a test request has `"test": true`. Drop `test` from `requests.create` for real recipients.

If your receiver was down for longer than the retries last, the answers are not lost. Read them with `requests.get`, or send the callback again with [requests.replayCallback](https://formstep.io/docs/developers/rest-api/#requests-replay-callback) or from the [Requests page](https://formstep.io/docs/requests/managing-requests/).

## Next steps

[Request callbacks](https://formstep.io/docs/developers/callbacks/) — The full payload, the three events, and the retry schedule.

[API reference](https://formstep.io/docs/developers/rest-api/) — Every method, with its parameters and responses.
