BitelioBitelio

Verifying signatures

Confirm a request genuinely came from Bitelio before you act on it

Verify the raw body — not a re-parsed object

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.

Headers

Every delivery carries:

HeaderWhat it is
Bitelio-SignatureComma-separated v1=<hex> parts, one per currently-valid signing secret. Accept the request if any part verifies — see below.
Bitelio-TimestampUnix 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-IdIdentifies this delivery. Stable across every retry of the same delivery — use it to deduplicate.
Bitelio-Event-TypeThe event name, e.g. email.delivery or shopify.order.paid.
Bitelio-Schema-VersionThe 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.

Check every part

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.

A complete example (Node.js)

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