Webhooks
Verify the signature of every webhook payload you receive.
A webhook endpoint is a URL open to the internet, and a plausible looking body proves nothing. An unverified payload could be forged, tampered with or replayed, so verify the signature of every payload before you process it. The endpoint secret you verify with starts with whsec_ and is shown exactly once in the console when you register the endpoint.
Every delivery carries a Frism-Signature header. The value is a list of key=value pairs separated by ,.
POST /webhooks/frism HTTP/1.1
Host: example.com
Content-Type: application/json
Frism-Event-Id: evt_01J8QH5R7T
Frism-Signature: t=1788257727,v2=d7e16839f487db479fa277fb11c2d6139225ea837c672df8a68e0452fd7ea772,v1=59ba753f14e68fd9aff609e623a55afd67db964cc881d66cb7aa09e3ef65ad58| Part | Meaning |
|---|---|
t | When the signature was produced, in unix seconds. Reject events older than 5 minutes. |
v2 | Hex HMAC-SHA256 digest of the string <t>.<raw body>, keyed with the endpoint secret. This is the value you verify. |
v1 | Legacy digest over the raw body alone. Sent alongside v2 until 2026-09-30 only; do not use it in new code. |
v1 carries no timestamp, so an intercepted payload replayed later still passes. Trust v2 only, and see the changelog for the v1 shutdown schedule.
Frism-Signature header on , and parse out t and v2.t is within 5 minutes of the current time. Reject otherwise.<t>.<raw body> from the raw body string exactly as it arrived, and compute the HMAC-SHA256 digest with the endpoint secret. It must be the received bytes, not a body re-serialized after JSON.parse.v2 with a timing-safe comparison. Only on a match do you parse the JSON and start processing.Because the digest is computed over t and the body joined with a dot, changing either one breaks the signature; that binding is what defeats replays. The 5 minute allowance on t exists to absorb delivery latency and clock drift, so keep your server clocks synchronized with NTP.
The built-in node:crypto module is all you need. No external library is required.
import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
export function verifyFrismSignature(
rawBody: string,
header: string,
secret: string,
): boolean {
// 1. Parse the header into key=value pairs.
const parts = new Map<string, string>();
for (const pair of header.split(",")) {
const index = pair.indexOf("=");
if (index > 0) parts.set(pair.slice(, index), pair.slice(index + ));
}
t = parts.get();
v2 = parts.get();
(t === undefined || v2 === undefined) false;
timestamp = Number(t);
(!Number.isInteger(timestamp)) false;
now = Math.floor(Date.now() / );
(Math.abs(now - timestamp) > TOLERANCE_SECONDS) false;
expected = createHmac(, secret)
.update(`${t}.${rawBody}`)
.digest();
received = Buffer.(v2, );
(received.length !== expected.length) false;
timingSafeEqual(expected, received);
}
handleFrismEvent(request: Request): Promise<Response> {
rawBody = request.text();
header = request.headers.get() ?? ;
secret = process.env.FRISM_WEBHOOK_SECRET ?? ;
(!verifyFrismSignature(rawBody, header, secret)) {
Response(null, { status: });
}
event = JSON.parse(rawBody) as { id: string; : string };
console.log(`verified ${event.} (${event.id})`);
Response(null, { status: });
}| Mistake | What goes wrong |
|---|---|
JSON.parse before verifying | A string re-serialized after parsing differs in whitespace and key order, so the digest never matches. Touching unverified data is a risk in itself. Always verify the raw body string. |
Comparing digests with === | Plain string comparison takes a different amount of time depending on where it fails, which leaks timing information. Use timingSafeEqual. |
Checking v1 instead of v2 | v1 has no timestamp, so it cannot stop a replay of an old payload, and it stops being sent after 2026-09-30. |
Respond 400 and do not process the payload. Answering 2xx to a failed verification would record the delivery as successful and stop the retries, so failures must surface as 4xx. Log the Frism-Event-Id and the failure reason, but treat the body as untrusted. Even if a deployment mistake makes you reject genuine events, APIFuse , so the events come back once your verifier is fixed.
Signing a request yourself with openssl lets you check the verifier before deploying. The body below is a reservation.confirmed event carrying the . Vary T or BODY in the script to confirm the handler rejects a mismatched digest and a stale timestamp alike.
SECRET="whsec_mJq82LkabQfw" # the endpoint secret from the console
BODY='{"id":"evt_01J8QH5R7T","type":"reservation.confirmed","created_at":"2026-09-01T10:15:27Z","data":{"object":{"id":"rsv_01J8QDGT2K","status":"confirmed","restaurant_id":"res_01J8Q9W3TE","starts_at":"2026-09-02T19:00:00+09:00","party_size":2}}}'
T=$(date +%s)
V2=$(printf "%s.%s" "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -X POST http://localhost:3000/webhooks/frism \
-H "Content-Type: application/json" \
-H "Frism-Event-Id: evt_01J8QH5R7T" \
-H "Frism-Signature: t=$T,v2=$V2" \
--data "$BODY"