# Receive and verify the callback

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

## Receive and verify the callback

A request with a callbackUrl ends with a signed POST to that URL: the answers when the recipient submits, or word that it expired or was canceled. This guide runs a receiver on your machine, checks the signature, and prints the answers.

<p>
  You need an API token and a published form, as in <a href="/guides/rest-api/send-a-request">Send a request with the REST API</a>, and Node
  or Python on your machine. What a callback carries, and how retries work, is in <a href="/requests/callbacks">Callbacks & signing</a>.
</p>

<h2 id="secret">1. Copy the signing secret</h2>

<p>
  formbase signs every callback with your workspace's request signing secret. Open <strong>OAuth and API Keys</strong> in the workspace
  sidebar and find the <strong>Request signing secret</strong> card:
</p>

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

```
export FORMBASE_SIGNING_SECRET='rqs_...'
```

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

<h2 id="receiver">2. Write the receiver</h2>

<p>
  Save the <code>verify</code> function from <a href="/requests/callbacks#verify">Callbacks & signing</a> next to your receiver: as{' '}
  <code>verify.mjs</code> for Node, since it uses <code>import</code>, or as <code>verify.py</code> for Python. The receiver reads the raw
  body, checks the signature, and only then parses the JSON:
</p>

  
    
```
import http from 'node:http'
const secret = process.env.FORMBASE_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-formbase-signature'] ?? ''
      if (!verifyFormbaseCallback(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)
```

  
  
    
```
import json, os
from http.server import BaseHTTPRequestHandler, HTTPServer
from verify import verify_formbase_callback
SECRET = os.environ['FORMBASE_SIGNING_SECRET']
class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        raw_body = self.rfile.read(int(self.headers['Content-Length']))
        signature = self.headers.get('X-formbase-Signature', '')
        if not verify_formbase_callback(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()
```

  

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

<h2 id="tunnel">3. Run it and give it a public URL</h2>

<p>Start the receiver:</p>

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

<p>
  formbase only calls public HTTPS addresses, so <code>localhost</code> will not do. While you test, a tunnel gives your machine one.{' '}
  <a href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/do-more-with-tunnels/trycloudflare/">
    Cloudflare's quick tunnel
  </a>{' '}
  needs no account. Run it in a second terminal:
</p>

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

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

<h2 id="request">4. Send a request with a callback URL</h2>

<p>
  Create a test request as in the <a href="/guides/rest-api/send-a-request#create">previous guide</a>, and add <code>callbackUrl</code> with
  the tunnel address. Use your own form ID:
</p>

```
curl -X POST https://api.formbase.so/api/v1 \
  -H "Authorization: Bearer $FORMBASE_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/formbase",
      "test": true
    }
  }'
```

<p>
  Use a new <code>idempotencyKey</code>: the same key with a different body is rejected. Open the <code>url</code> from the reply, answer
  the form, and submit.
</p>

<h2 id="see-it">5. See the callback arrive</h2>

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

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

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

<h2 id="production">Before you go live</h2>

<ul>
  <li>
    <strong>Answer 2xx within 10 seconds.</strong> formbase marks the callback delivered on any 2xx. A 5xx, a 408, a 429 or a timeout is
    retried for about four hours; any other 4xx, like the 401 above, stops the retries. See{' '}
    <a href="/requests/callbacks#retries">Retries</a>.
  </li>
  <li>
    <strong>
      Branch on <code>type</code>.
    </strong>{' '}
    The same URL also hears <code>request.expired</code> and <code>request.canceled</code>, which carry no answers.
  </li>
  <li>
    <strong>Handle each event once.</strong> A retry carries the same <code>id</code>. Store the ids you have handled and skip a repeat.
  </li>
  <li>
    <strong>
      Check <code>test</code>.
    </strong>{' '}
    A callback from a test request has <code>"test": true</code>. Drop <code>test</code> from
    <code>requests.create</code> for real recipients.
  </li>
</ul>

<p>
  If your receiver was down for longer than the retries last, the answers are not lost. Read them with <code>requests.get</code>, or send
  the callback again with <a href="/developers/rest-api#requests-replay-callback">requests.replayCallback</a> or from the{' '}
  <a href="/requests/managing-requests">Requests page</a>.
</p>

<h2 id="next">Next</h2>

<div class="not-prose grid gap-3 sm:grid-cols-2 mb-8">
  - [Callbacks & signing](/requests/callbacks) — The full payload, the three events, and the retry schedule.
  - [API methods](/developers/rest-api) — Every method, with its parameters and responses.
</div>
