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.
The signing secret
Section titled “The signing secret”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.
The headers
Section titled “The headers”| 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.
With ricochet-webhooks
Section titled “With ricochet-webhooks”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;}import osfrom ricochet_webhooks import WebhookVerificationError, verify
try: verified = verify(raw_body, request.headers, os.environ["RICOCHET_WEBHOOK_SECRET"]) # verified.id is the delivery ID: skip it if you have already handled it.except WebhookVerificationError as error: ... # error.reason says why; answer 401.sign(body, { id, timestamp, secrets }) (Python: sign(body, id=…, timestamp=…, secrets=…)) returns the webhook-signature Ricochet would send, for your own tests.
Without a package
Section titled “Without a package”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); });}import base64, hashlib, hmac, time
def verify_ricochet(raw_body: bytes, headers, secret: str) -> bool: msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp", "") signatures = headers.get("webhook-signature", "").split(" ") if not msg_id or not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300: return False
key = base64.b64decode(secret.removeprefix("whsec_")) expected = base64.b64encode( hmac.new(key, f"{msg_id}.{timestamp}.".encode() + raw_body, hashlib.sha256).digest() ).decode() return any(s.startswith("v1,") and hmac.compare_digest(s[3:], expected) for s in signatures)def verify_ricochet(raw_body, conn, "whsec_" <> secret) do key = Base.decode64!(secret)
with [id] <- Plug.Conn.get_req_header(conn, "webhook-id"), [timestamp] <- Plug.Conn.get_req_header(conn, "webhook-timestamp"), [signatures] <- Plug.Conn.get_req_header(conn, "webhook-signature"), {seconds, ""} <- Integer.parse(timestamp), true <- abs(System.system_time(:second) - seconds) <= 300 do expected = Base.encode64(:crypto.mac(:hmac, :sha256, key, [id, ".", timestamp, ".", raw_body]))
signatures |> String.split(" ") |> Enum.any?(fn "v1," <> received -> Plug.Crypto.secure_compare(received, expected) _ -> false end) else _ -> false endendIdempotency
Section titled “Idempotency”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.