Verify a Signature
Every delivery is signed with your subscription’s secret. Verify the signature before you act on a notification — an unverified request is just an HTTP call from a stranger.
The signed content is exactly:
{webhook-id}.{webhook-timestamp}.{raw body}— the three values joined by literal dots. Compute HMAC-SHA256 over its UTF-8 bytes,
base64-encode the result, and prefix v1,. Compare that against the values in the
webhook-signature header. If none match, reject the delivery with 401.
The three things that go wrong
Section titled “The three things that go wrong”Nearly every verification bug is one of these.
1. The secret is used verbatim, as UTF-8 bytes. It looks like an encoded value because
it starts with whsec_, but do not base64-decode it. Pass the string to your HMAC
function exactly as it was given to you, including the prefix.
2. Sign the raw body, exactly as received. Parsing JSON and re-serialising it will not reproduce the bytes we signed — key order, whitespace and number formatting all shift, and the signature fails for every delivery. Capture the raw body before any JSON middleware touches it.
3. webhook-signature may hold several values. During a secret rotation it carries both
the current and the previous signature, space-separated. Accept the delivery if any value
matches. Code that rejects on the first mismatch will drop live traffic mid-rotation.
using System.Security.Cryptography;using System.Text;
public static class KomplettWebhookSignature{ public static bool Verify( string secret, string webhookId, string webhookTimestamp, string rawBody, string? signatureHeader) { if (string.IsNullOrWhiteSpace(signatureHeader)) { return false; }
var signedContent = $"{webhookId}.{webhookTimestamp}.{rawBody}"; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var expected = "v1," + Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(signedContent)));
// During secret rotation the header carries several space-separated values; any match is valid. foreach (var candidate in signatureHeader.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { if (CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(candidate), Encoding.UTF8.GetBytes(expected))) { return true; } }
return false; }}In ASP.NET Core, read the raw body before model binding — for example by buffering
HttpContext.Request.Body in middleware, or by binding the action parameter as a string
rather than a typed model.
import crypto from "node:crypto";
export function verify(secret, webhookId, webhookTimestamp, rawBody, signatureHeader) { if (!signatureHeader) return false;
const signedContent = `${webhookId}.${webhookTimestamp}.${rawBody}`; const expected = "v1," + crypto.createHmac("sha256", secret).update(signedContent, "utf8").digest("base64"); const expectedBuf = Buffer.from(expected);
// During secret rotation the header carries several space-separated values; any match is valid. return signatureHeader .split(" ") .filter(Boolean) .some((candidate) => { const candidateBuf = Buffer.from(candidate.trim()); return ( candidateBuf.length === expectedBuf.length && crypto.timingSafeEqual(candidateBuf, expectedBuf) ); });}Wiring it up in Express, which is where failure mode 2 usually bites:
// express.raw() keeps the exact bytes. express.json() would replace them and// verification would fail for every delivery.app.post( "/webhooks/komplett", express.raw({ type: "application/json" }), (req, res) => { const rawBody = req.body.toString("utf8");
if (!verify( process.env.KOMPLETT_WEBHOOK_SECRET, req.get("webhook-id"), req.get("webhook-timestamp"), rawBody, req.get("webhook-signature"), )) { return res.sendStatus(401); }
const event = JSON.parse(rawBody); // ... see /webhooks/examples/handle-an-order-placed-event/ res.sendStatus(204); },);Reject stale deliveries
Section titled “Reject stale deliveries”A valid signature proves the request came from us. It does not prove the request is recent — anyone who captures a signed delivery could replay it later, and the signature would still check out.
Compare webhook-timestamp against your own clock and reject deliveries outside a
tolerance of about five minutes. That bounds the window in which a captured request is
useful without being so tight that ordinary clock drift rejects legitimate traffic.
Both samples above compare signatures in fixed time
(CryptographicOperations.FixedTimeEquals, crypto.timingSafeEqual) rather than with
==. A plain string comparison returns as soon as it finds a differing byte, and that
timing difference can leak enough for an attacker to reconstruct a valid signature one byte
at a time.