Verify a Signed Webhook
Hard · Route Handlers
Services like Stripe call your route handler (a webhook) when something happens, for example a paid invoice. Anyone can send a POST to that URL, so the sender signs every request and you must verify it before trusting the body.
The header looks like stripe-signature: t=1767225600,v1=<hex>. v1 is the HMAC-SHA256 of the string ` ${t}.${rawBody} with your secret, as lowercase hex. Several v1` values can appear while the sender rotates secrets; one match is enough.
Write POST(request, now) (now in seconds is passed in so the tests are stable):
1. No header: 400 { "error": "Missing signature" }.
2. Split the header on , into key=value pairs (split at the first =, trim both). t must be digits only. Without a valid t or without any v1: 400 { "error": "Malformed signature" }. Other keys (like v0) are ignored.
3. |now - t| > 300: 401 { "error": "Timestamp outside tolerance" }. This stops attackers from replaying an old captured request.
4. Read the body with await request.text() and compute the expected signature with crypto.subtle (importKey("raw", ...) with { name: "HMAC", hash: "SHA-256" }, then sign); toHex is in the starter. Compare with a constant-time function (check the length, then XOR every character code and check that the result is 0) so response times do not leak how many characters matched. No match: 401 { "error": "Invalid signature" }.
5. Only now JSON.parse the raw text. Invalid JSON: 400 { "error": "Invalid JSON" }. Otherwise 200 { "received": true, "type": event.type }.
Why the raw text? The signature covers the exact bytes that were sent. request.json() followed by JSON.stringify changes spacing and key order, so the HMAC would no longer match. A body can only be read once, so read it as text and parse that.