Webhook signature verifier
Paste the signature headers, the raw request body and your secret to check a signed webhook request: the tool recomputes the HMAC-SHA256, compares it with the one the sender put in the headers, checks the timestamp against a 5-minute window and tells you why a check fails. It runs in your browser, so the secret is not sent anywhere.
It checks two schemes. The first is the one Notifly's HTTP Request step signs with: a novu-signature header of the form t=<Unix milliseconds>,v1=<hex HMAC-SHA256>, computed over the string <t>.<body> with the secret key of the environment the workflow runs in. The second is Standard Webhooks, an open specification many webhook senders follow: webhook-id, webhook-timestamp (Unix seconds) and webhook-signature (v1,<base64 HMAC-SHA256>) headers, signed over <id>.<timestamp>.<body> with a whsec_ secret.
How to use it
- Pick the scheme. novu-signature when the request carries a t=…,v1=… header; Standard Webhooks when it carries webhook-id, webhook-timestamp and webhook-signature.
- Paste the headers. Copy the header values from the request your endpoint received, exactly as they arrived.
- Paste the raw body. Use the body exactly as it arrived, before any JSON parsing. For novu-signature, leave it empty for a request without a body: it is signed as {}.
- Enter the secret and read the verdict. A mismatch comes with the likely cause: a re-formatted body, whitespace around the key, a secret used without decoding, or a stale timestamp. Switch to Sign to produce headers for your own tests.
Where Notifly uses this
Limits this tool checks
- novu-signature: timestamp window either side of the receiver's clock (Notifly's framework SDK and docs example)300 secondsNotifly docs: HTTP request step signing, checked
- Standard Webhooks: timestamp window either side of the receiver's clock (reference library)300 secondsstandard-webhooks JavaScript library, checked
Use it from code, no key
POST /api/tools/verify-webhook-signature takes the same input and answers the report as JSON, or as Markdown with Accept: text/markdown. No account or key; 30 requests a minute and 300 a day per address; errors are RFC 9457 problem details. The OpenAPI description covers every free tool API.
curl -s https://notifly.io/api/tools/verify-webhook-signature \
-H "Content-Type: application/json" \
-d '{"header":"t=1760000000000,v1=52bfadab8ed8e8457ba8587bb59df974b9cedb287c640381ab6045d65f39db07","body":"{\"orderId\":\"1042\",\"status\":\"shipped\"}","secret":"sk_test_example","ignoreTimestamp":true}'Code to copy
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received (a string), before any JSON parsing.
export function verifyNotiflyRequest(rawBody, header, secretKey, toleranceMs = 5 * 60 * 1000) {
const fields = {};
for (const part of header.split(",")) {
const at = part.indexOf("=");
const key = part.slice(0, at).trim();
if (at > 0 && !(key in fields)) fields[key] = part.slice(at + 1);
}
const t = Number(fields.t);
if (!Number.isFinite(t) || Math.abs(Date.now() - t) > toleranceMs) return false;
const expected = createHmac("sha256", secretKey).update(`${t}.${rawBody === "" ? "{}" : rawBody}`).digest("hex");
const received = Buffer.from(fields.v1 ?? "");
return received.length === expected.length && timingSafeEqual(received, Buffer.from(expected));
}import { createHmac, timingSafeEqual } from "node:crypto";
// headers: the request headers, lower-cased; rawBody: the body exactly as received (a string).
export function verifyStandardWebhook(rawBody, headers, secret, toleranceSeconds = 5 * 60) {
const id = headers["webhook-id"];
const timestamp = Number.parseInt(headers["webhook-timestamp"], 10);
if (!id || !Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = Buffer.from(createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64"));
return (headers["webhook-signature"] ?? "").split(" ").some((entry) => {
const [version, signature] = entry.split(",");
const received = Buffer.from(signature ?? "");
return version === "v1" && received.length === expected.length && timingSafeEqual(received, expected);
});
}import base64
import hashlib
import hmac
import time
def verify_standard_webhook(raw_body: bytes, headers: dict, secret: str, tolerance_seconds: int = 300) -> bool:
msg_id = headers.get("webhook-id", "")
timestamp = headers.get("webhook-timestamp", "")
if not msg_id or not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{int(timestamp)}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
return any(
version == "v1" and hmac.compare_digest(signature, expected)
for version, _, signature in (entry.partition(",") for entry in headers.get("webhook-signature", "").split(" "))
)Frequently asked questions
Why does my webhook signature not match?
Almost always because the body was changed before the check: a framework parsed the JSON and you re-serialized it, which changes spacing and key order. Verify the raw bytes you received. The other usual causes are the wrong environment's key (Development and Production differ), whitespace copied with the key, a Standard Webhooks secret used as text instead of base64-decoded, and, for novu-signature, a request without a body, which is signed as {}.
What exactly does Notifly sign?
The string <t>.<body>, where t is the time of signing in Unix milliseconds and body is the JSON text of the request body as sent. The HMAC-SHA256 of that string, keyed with the environment's secret key, goes in the header as lowercase hex: novu-signature: t=<t>,v1=<hex>.
How do I verify a Standard Webhooks signature?
Base64-decode the secret after its whsec_ prefix, compute the HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<raw body> with it, base64-encode the result and compare it, in constant time, with each v1,<signature> entry of the space-separated webhook-signature header: any match passes, which lets the sender rotate keys. Refuse a webhook-timestamp more than 5 minutes from your clock. The snippets on this page do exactly that.
How old can a signed request be?
Notifly's framework SDK and the Standard Webhooks reference library both refuse a timestamp more than 5 minutes away from the receiver's clock, in either direction. That bounds replays of a captured request. Note the units: novu-signature sends milliseconds, Standard Webhooks sends seconds. To check an old captured request here, tick the option to ignore the timestamp.
Is it safe to paste my secret key?
The page computes the HMAC in your browser with the Web Crypto API and sends nothing. The API version receives the key you send it, keeps nothing and logs nothing, but a production key should not leave your servers: use a Development environment key or the example key when trying it.
Sending these for real? Notifly sends push, email, SMS, in-app and chat from one workflow and one API call. Start free.