Skip to content

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.


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);
},
);

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.