Skip to content

Verifying signatures

Ricochet signs every delivery to an HTTP destination with the destination’s signing secret, following Standard Webhooks. Checking it proves the request came from Ricochet, whichever provider sent the original, and that the body was not changed on the way.

Each HTTP destination gets a signing secret, whsec_ followed by base64, shown once when the destination is created. Store it in your app’s configuration, for example as RICOCHET_WEBHOOK_SECRET.

To rotate it, add a new secret on the destination. Both secrets sign every delivery for an overlap (a day by default), so deploy the new secret to your app, and the old one stops signing when the overlap ends. During the overlap your app may check against either.

Header Value
webhook-id The delivery’s ID: the same on every retry, different for a replay.
webhook-timestamp When this attempt started, in Unix seconds.
webhook-signature v1,<base64>, space-separated, one per current signing secret.

Each signature is the base64 HMAC-SHA256, keyed with the secret’s decoded bytes (the part after whsec_), of:

<webhook-id>.<webhook-timestamp>.<raw body>

A request is genuine if any v1 signature matches and the timestamp is within 5 minutes of your clock. Compare in constant time, and use the raw body exactly as it arrived: parsing and re-serializing JSON changes the bytes.

ricochet-webhooks does this check in one call, for Node and Python. It takes the raw body, the request’s headers in any framework’s form, and one secret or a list (during a rotation). It returns the webhook-id and the timestamp, or throws an error whose reason is missing_header, invalid_timestamp, timestamp_out_of_range, no_matching_signature or invalid_secret.

import { verify, WebhookVerificationError } from 'ricochet-webhooks';
try {
const { id } = verify(rawBody, request.headers, process.env.RICOCHET_WEBHOOK_SECRET);
// id is the delivery ID: skip it if you have already handled it.
} catch (error) {
if (error instanceof WebhookVerificationError) {
// error.reason says why; answer 401.
}
throw error;
}

sign(body, { id, timestamp, secrets }) (Python: sign(body, id=…, timestamp=…, secrets=…)) returns the webhook-signature Ricochet would send, for your own tests.

import { createHmac, timingSafeEqual } from 'node:crypto';
export function verifyRicochet(rawBody, headers, secret) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = (headers['webhook-signature'] ?? '').split(' ');
if (!id || !/^\d+$/.test(timestamp ?? '')) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key).update(`${id}.${timestamp}.`).update(rawBody).digest();
return signatures.some((s) => {
if (!s.startsWith('v1,')) return false;
const received = Buffer.from(s.slice(3), 'base64');
return received.length === expected.length && timingSafeEqual(received, expected);
});
}

Ricochet delivers at least once: a retry after a timeout can reach a handler that already processed the first attempt. webhook-id (also sent as Idempotency-Key) is the same on every attempt of a delivery, so record it when you handle an event and skip a delivery whose ID you have already seen. A replay is a new delivery with a new ID, so replays still go through.