# Verifying signatures (/webhooks/verifying-signatures)

<Callout title="Verify the raw body — not a re-parsed object" type="warn">
  The signature is computed over the exact bytes of the request body Bitelio sent. If your framework parses the JSON body before your handler runs and you re-sign the parsed object (e.g. `JSON.stringify(req.body)`), the re-serialised bytes almost never match the original — different key order, different whitespace, a reformatted number — and verification fails on every legitimate request. This is by far the most common reason signature verification "doesn't work." Capture the raw body **before** any JSON-parsing middleware touches the request, and verify against that.
</Callout>

## Headers

Every delivery carries:

| Header                   | What it is                                                                                                                                                                           |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `Bitelio-Signature`      | Comma-separated `v1=<hex>` parts, one per currently-valid signing secret. Accept the request if **any** part verifies — see below.                                                   |
| `Bitelio-Timestamp`      | Unix time (seconds) when the request was signed. It's part of the signed string itself, so you can reject a request whose signature is valid but whose timestamp is implausibly old. |
| `Bitelio-Idempotency-Id` | Identifies this delivery. Stable across every retry of the same delivery — use it to deduplicate.                                                                                    |
| `Bitelio-Event-Type`     | The event name, e.g. `email.delivery` or `shopify.order.paid`.                                                                                                                       |
| `Bitelio-Schema-Version` | The payload format the endpoint was created with. Currently always `v1`.                                                                                                             |

Requests are sent with `Content-Type: application/json`.

## How the signature is built

Bitelio computes an HMAC-SHA256, keyed with your endpoint's signing secret, over the string:

```
{timestamp}.{raw request body}
```

— the `Bitelio-Timestamp` value, a literal `.`, then the exact bytes of the JSON body — and sends the result hex-encoded. Putting the timestamp inside the signed string (rather than alongside it, unsigned) is what lets you reject an old request even when its signature is otherwise valid: nobody can change the timestamp without invalidating the signature that came with it.

## Why `Bitelio-Signature` can hold more than one value

`Bitelio-Signature` is comma-separated because more than one secret can be valid for your endpoint at once, during a **secret rotation**: when you rotate, the previous secret keeps signing requests alongside the new one for the next 24 hours. During that window, every request carries two `v1=<hex>` parts — one per secret — so you can swap your stored secret for the new one at your own pace without a single event failing to verify in between.

<Callout title="Check every part" type="warn">
  Accept the request if **any** `v1=` part verifies. A receiver that only checks the first part starts rejecting roughly half its traffic the moment a rotation begins, and keeps doing so until the rotation window closes 24 hours later.
</Callout>

## A complete example (Node.js)

```javascript
import {createHmac, timingSafeEqual} from 'node:crypto';
import {createServer} from 'node:http';

// The current secret is always required. The previous one only matters
// while a rotation is in flight (see "Why Bitelio-Signature can hold more
// than one value" above) — leave it unset the rest of the time.
const SECRETS = [
  process.env.BITELIO_WEBHOOK_SECRET,
  process.env.BITELIO_WEBHOOK_SECRET_PREVIOUS,
].filter(Boolean);

// Not enforced by Bitelio — this is a receiver-side judgment call to reject
// a signature that's valid but suspiciously old. Five minutes is a
// reasonable starting point; widen it if your own infrastructure adds
// latency before your handler sees the request.
const TOLERANCE_SECONDS = 5 * 60;

function isValidSignature(rawBody, timestampHeader, signatureHeader) {
  const timestamp = Number(timestampHeader);
  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
  if (!signatureHeader) return false;

  const signedPayload = `${timestamp}.${rawBody}`;
  const parts = signatureHeader.split(',').map(part => part.trim());

  return SECRETS.some(secret => {
    const expected = Buffer.from(
      createHmac('sha256', secret).update(signedPayload).digest('hex'),
      'hex',
    );

    return parts.some(part => {
      if (!part.startsWith('v1=')) return false;
      const candidate = Buffer.from(part.slice('v1='.length), 'hex');
      // timingSafeEqual instead of === or .equals(): a plain comparison
      // returns as soon as it hits the first mismatched byte, which leaks
      // how much of a guess was correct through response time. Both
      // buffers must be the same length before calling it, since it
      // throws otherwise.
      return candidate.length === expected.length && timingSafeEqual(candidate, expected);
    });
  });
}

const server = createServer((req, res) => {
  if (req.method !== 'POST') {
    res.writeHead(405).end();
    return;
  }

  const chunks = [];
  req.on('data', chunk => chunks.push(chunk));
  req.on('end', () => {
    // The raw bytes, collected before anything parses them. See the
    // warning at the top of this page — this is the line that matters most.
    const rawBody = Buffer.concat(chunks).toString('utf8');

    const ok = isValidSignature(
      rawBody,
      req.headers['bitelio-timestamp'],
      req.headers['bitelio-signature'],
    );
    if (!ok) {
      res.writeHead(401, {'Content-Type': 'text/plain'}).end('invalid signature');
      return;
    }

    const event = JSON.parse(rawBody);
    console.log(`received ${event.type} (idempotency id: ${req.headers['bitelio-idempotency-id']})`);

    // Do your work, then acknowledge quickly — anything outside 200-299 is
    // treated as a failure and retried. See "Delivery guarantees".
    res.writeHead(200).end('ok');
  });
});

server.listen(3000, () => console.log('listening on :3000'));
```

Save this as `verify-webhook.mjs` and run it with `node verify-webhook.mjs` — no dependencies required. Node lower-cases incoming header names, so `req.headers['bitelio-timestamp']` is correct regardless of how the header was cased on the wire.

If you're using a framework instead of raw `http`, the one thing to preserve is capturing the body before it's parsed. In Express, for example, mount `express.raw({type: 'application/json'})` on this specific route (not globally) so `req.body` is the untouched `Buffer`, and pass `req.body.toString('utf8')` as `rawBody` above.

## What's next

<Cards>
  <Card title="Delivery guarantees" href="/webhooks/delivery-guarantees">
    Retries, degradation, retention, and how a replay differs from a retry.
  </Card>

  <Card title="Webhooks overview" href="/webhooks">
    Back to registering an endpoint and what you receive.
  </Card>
</Cards>
