Skip to content

Webhook signature verifier

Paste the raw body, the secret and the signature header. See at once whether an HMAC signature is valid, and if not, why. Works for Stripe, GitHub, Shopify, Standard Webhooks (Svix), Eventeer and any generic HMAC.

Everything runs in your browser. Nothing you paste is sent anywhere or stored. The page makes no network calls to check a signature.

Exactly the bytes received. Do not pretty-print or re-serialize.

Re-checks as you type.

Result

Waiting for input

Fill in the body, secret and header to verify a signature.

How each provider signs its webhooks

Every scheme below is an HMAC over the raw request body with a shared secret. They differ in what else is signed, how the result is encoded and where it is sent. Always compare with a constant-time function.

Stripe

The Stripe-Signature header looks like t=1492774577,v1=5257a869…. ComputeHMAC-SHA256(secret, "<t>.<raw body>") as lowercase hex and compare it with everyv1 value (there are several while a secret is being rolled). Ignore other schemes such as v0. Stripe's libraries reject timestamps more than five minutes away. The secret is the whole whsec_… string, used as-is.

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyStripe(rawBody, header, secret, toleranceSec = 300) {
  const parts = header.split(",").map((p) => p.trim().split("="));
  const t = parts.find(([k]) => k === "t")?.[1];
  const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v);
  if (!t || sigs.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return sigs.some(
    (s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
  );
}

GitHub

X-Hub-Signature-256 is sha256= plus the lowercase hex HMAC-SHA256 of the raw body, keyed with the webhook secret. There is no timestamp. The older X-Hub-Signature uses SHA-1; do not use it.

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyGitHub(rawBody, header, secret) {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
  return header.length === expected.length && timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}

Shopify

X-Shopify-Hmac-Sha256 is the base64 HMAC-SHA256 of the raw body, keyed with your app's client secret. There is no prefix and no timestamp in the signature.

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyShopify(rawBody, header, clientSecret) {
  const expected = createHmac("sha256", clientSecret).update(rawBody).digest("base64");
  return header.length === expected.length && timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}

Standard Webhooks and Svix

Three headers: webhook-id, webhook-timestamp and webhook-signature. The signed content is<id>.<timestamp>.<body>. The signature header holds space-separated entries likev1,<base64>. The secret is whsec_ followed by base64: decode that part and use the bytes as the key. The spec leaves the tolerance to you; five minutes is the usual choice.

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyStandardWebhooks(rawBody, headers, secret, toleranceSec = 300) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${ts}.${rawBody}`).digest("base64");
  return headers["webhook-signature"]
    .split(" ")
    .filter((s) => s.startsWith("v1,"))
    .some((s) => {
      const got = s.slice(3);
      return got.length === expected.length && timingSafeEqual(Buffer.from(got), Buffer.from(expected));
    });
}

Eventeer

X-Eventeer-Signature: t=1761930000,v1=5257a869… where v1 = hex(HMAC_SHA256(secret, "<t>.<raw body>")), the same shape as Stripe. When a request carries an Idempotency-Key a v2 value is added:hex(HMAC_SHA256(secret, "v2.<t>.<key byte length>.<key>.<raw body>")). It ties the key to the signature so a captured request cannot be replayed with another key. This tool checks v1, and v2 when you fill in the key.

import { createHmac, timingSafeEqual } from "node:crypto";

function verifyEventeer(rawBody, header, secret, toleranceSec = 300) {
  const items = header.split(",").map((p) => p.trim().split("="));
  const t = items.find(([k]) => k === "t")?.[1];
  const sigs = items.filter(([k]) => k === "v1").map(([, v]) => v);
  if (!t || sigs.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSec) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return sigs.some(
    (s) => s.length === expected.length && timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
  );
}

Common mistakes

  • Verifying the parsed body. Frameworks that parse JSON before your handler hand you an object, not the bytes that were signed. Read the raw body (for example express.raw()) and verify before parsing.
  • Re-serialized JSON. JSON.stringify(JSON.parse(body)) changes whitespace, key escaping and number formatting. One changed byte is a different signature.
  • Wrong encoding. Stripe, GitHub and Eventeer use hex. Shopify and Standard Webhooks use base64. Comparing a hex digest with a base64 header never matches.
  • Clock skew. Timestamped schemes reject requests more than the tolerance away, in either direction. Keep your server clock synced with NTP.
  • Secret handling. Standard Webhooks secrets must be base64-decoded after the whsec_ prefix; Stripe's must not. Trailing newlines copied from a dashboard also break the key.
  • Plain string comparison. === can leak timing information. Use crypto.timingSafeEqual or your language's equivalent.

How Eventeer signs destination webhooks

Eventeer forwards stored events to your destinations as signed HTTPS requests with the X-Eventeer-Signature scheme above, at-least-once, with eight retries over about 24 hours and a delivery log you can inspect. Paste a failing delivery into this page to see why your verifier rejects it. The same scheme is used to authenticate signed inbound webhooks on a listener. See the API docs for the details.

Frequently asked questions

Is it safe to paste my webhook secret here?

The verifier runs entirely in your browser with the Web Crypto API. It makes no network requests and stores nothing, so the secret never leaves your device. Even so, prefer a test-mode or throwaway secret for anything sensitive.

Why does my signature not match even though the secret is right?

The most common cause is a modified body. Providers sign the exact bytes they sent, so a parsed and re-serialized JSON body, a trimmed trailing newline or different whitespace will not match. Other causes are a wrong encoding (hex versus base64), a secret with the wrong prefix handling, or a clock more than five minutes off.

How does Eventeer sign its webhooks?

Eventeer sends X-Eventeer-Signature: t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 of "<t>.<raw body>" with your secret. When a request carries an Idempotency-Key it also sends a v2 value that binds the key. Receivers should reject timestamps more than 300 seconds away.

Receive, store and replay your webhooks

Eventeer gives every source an unguessable URL, keeps each event for your plan's retention, and delivers to your destinations with signed requests and retries.