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