Webhooks
Verifying signatures
Every delivery is signed with your webhook secret, so you can prove it came from us and wasn't replayed. Check it before you act on an event.
The header
X-Passcode-Signature: t=1791381739,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd- t
- When we signed the delivery, in Unix seconds.
- v1
- Hex HMAC-SHA256 of
<t>.<raw body>, keyed with your webhook secret (the wholewhsec_…string). There may be more than onev1; accept the delivery if any of them matches.
Step by step
- 01Read the raw request body as bytes or text — before any JSON parsing.
- 02Split the header on
,, then each part on the first=. Taketand everyv1. - 03Reject the delivery if
tis more than 5 minutes from your clock. - 04Compute HMAC-SHA256 over
t+.+ raw body with your secret, as hex. - 05Compare it with each
v1in constant time. Any match: the event is genuine.
Verification code
Drop-in functions with no dependencies beyond the standard library. They implement exactly the checks above.
verifyPasscodeSignature
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
/**
* Verify an X-Passcode-Signature header against the raw request body.
* Header format: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">
*/
export function verifyPasscodeSignature(rawBody, header, secret) {
if (!header || !secret) return false;
const parts = header.split(",").map((part) => part.trim().split("="));
const timestamp = Number(parts.find(([key]) => key === "t")?.[1]);
const signatures = parts.filter(([key, value]) => key === "v1" && value).map(([, value]) => value);
if (!Number.isInteger(timestamp) || signatures.length === 0) return false;
// Reject old or future-dated deliveries (replay protection).
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`, "utf8").digest();
return signatures.some((signature) => {
const given = Buffer.from(signature, "hex");
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}In your framework
Next.js route handler · Flask
// app/webhooks/passcode/route.js (Next.js App Router)
import { verifyPasscodeSignature } from "@/lib/passcode";
export async function POST(request) {
const body = await request.text(); // the raw body: verify before parsing
const ok = verifyPasscodeSignature(
body,
request.headers.get("x-passcode-signature"),
process.env.PASSCODE_WEBHOOK_SECRET,
);
if (!ok) return new Response("Invalid signature", { status: 400 });
const event = JSON.parse(body);
if (event.type === "order.code_received") {
const { order } = event.data;
console.log(`Code for ${order.id}: ${order.code}`);
}
return new Response(null, { status: 204 });
}// Express: keep the body raw on this route so the signature can be checked.
app.post("/webhooks/passcode", express.raw({ type: "application/json" }), (req, res) => {
const body = req.body.toString("utf8");
if (!verifyPasscodeSignature(body, req.get("X-Passcode-Signature"), process.env.PASSCODE_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(body);
// …handle event.type…
res.sendStatus(204);
});Common pitfalls
- Parsed, then re-serialized JSON. Whitespace and key order change and the signature no longer matches. Always verify the raw body.
- Clock drift. A server clock more than 5 minutes off rejects every delivery. Keep NTP on.
- Trimming the secret. Use the whole value including the
whsec_prefix. - After rotating the secret, deliveries are signed with the new one immediately. Update your environment straight away.