Skip to content
apikey.tools
Guide

How to Verify Webhook Signatures (Stripe, GitHub, Shopify, Slack)

Exactly which bytes Stripe, GitHub, Shopify, Slack and Standard Webhooks sign, with working Node.js and Python verification code, and the raw-body, timing and replay mistakes that make verification silently fail.

By Abdessamad Bettal · Updated · 6 min read

A webhook endpoint is a public URL that accepts POST requests from anyone. The signature header is the only thing separating a real event from a forged one. Every major provider uses the same core idea — an HMAC-SHA256 of the request body keyed with a shared secret — but each one signs slightly different bytes and encodes the result differently. Most verification bugs come from those differences.

How webhook signatures work

  1. You and the provider share a secret, usually shown once in the provider's dashboard.
  2. When an event fires, the provider computes HMAC-SHA256(secret, signed_content) and sends the result in a header.
  3. Your endpoint recomputes the HMAC over the bytes it received and compares the two values.

Only someone holding the secret can produce a matching signature, and changing a single byte of the body changes the result. If the provider includes a timestamp in the signed content, you can also reject old requests that an attacker replays.

What each provider signs

ProviderHeaderSigned contentEncoding
StripeStripe-Signature{t}.{raw body}hex, in v1=
GitHubX-Hub-Signature-256raw bodyhex, prefixed sha256=
ShopifyX-Shopify-Hmac-Sha256raw bodyBase64
SlackX-Slack-Signaturev0:{timestamp}:{raw body}hex, prefixed v0=
Standard Webhooks / Svixwebhook-signature{id}.{timestamp}.{raw body}Base64, prefixed v1,

Two details catch people out. Stripe's header contains the timestamp and possibly several signatures, formatted like t=1729771200,v1=5257a8…, and you must parse it before comparing. Standard Webhooks secrets look like whsec_ followed by Base64: the HMAC key is the Base64-decoded bytes after the prefix, not the string itself. Stripe, by contrast, uses its whole whsec_ string as the key.

Rule one: verify the raw body

The signature covers the exact bytes the provider sent. If your framework parses the JSON before your handler runs, re-serialising it with JSON.stringify almost never reproduces those bytes — key order, whitespace and Unicode escaping all differ. The HMAC will not match, and you will spend an afternoon suspecting the secret.

Capture the raw body before any parser touches it.

Node.js with Express (GitHub)

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

const app = express();

app.post(
  "/webhooks/github",
  express.raw({ type: "application/json" }), // req.body is a Buffer
  (req, res) => {
    const header = req.get("X-Hub-Signature-256") ?? "";
    const expected =
      "sha256=" +
      createHmac("sha256", process.env.GITHUB_WEBHOOK_SECRET)
        .update(req.body)
        .digest("hex");

    const a = Buffer.from(header);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
      return res.status(401).send("Invalid signature");
    }

    const event = JSON.parse(req.body.toString("utf8"));
    // handle event…
    res.sendStatus(204);
  }
);

Next.js App Router route handler (Stripe)

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

const TOLERANCE_SECONDS = 300;

export async function POST(request: Request) {
  const body = await request.text(); // raw body — do not call request.json()
  const header = request.headers.get("stripe-signature") ?? "";

  const parts = Object.fromEntries(
    header.split(",").map((pair) => pair.split("=") as [string, string])
  );
  const timestamp = Number(parts.t);
  const age = Math.abs(Date.now() / 1000 - timestamp);
  if (!timestamp || age > TOLERANCE_SECONDS) {
    return new Response("Stale or missing timestamp", { status: 400 });
  }

  const expected = createHmac("sha256", process.env.STRIPE_WEBHOOK_SECRET!)
    .update(`${timestamp}.${body}`)
    .digest("hex");

  const signatures = header
    .split(",")
    .filter((pair) => pair.startsWith("v1="))
    .map((pair) => pair.slice(3));

  const valid = signatures.some(
    (sig) =>
      sig.length === expected.length &&
      timingSafeEqual(Buffer.from(sig), Buffer.from(expected))
  );
  if (!valid) return new Response("Invalid signature", { status: 400 });

  const event = JSON.parse(body);
  // handle event…
  return new Response(null, { status: 200 });
}

In production, prefer the provider's SDK (stripe.webhooks.constructEvent) — it does exactly this. Writing it by hand once is still the fastest way to understand why the SDK call fails.

Python with Flask (Shopify)

python
import base64, hashlib, hmac, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["SHOPIFY_API_SECRET"].encode()

@app.post("/webhooks/shopify")
def shopify_webhook():
    raw = request.get_data()  # bytes, before any JSON parsing
    digest = hmac.new(SECRET, raw, hashlib.sha256).digest()
    expected = base64.b64encode(digest).decode()
    received = request.headers.get("X-Shopify-Hmac-Sha256", "")

    if not hmac.compare_digest(expected, received):
        abort(401)
    return "", 200

Python (Slack)

python
import hashlib, hmac, time

def verify_slack(signing_secret: str, headers, raw_body: bytes) -> bool:
    timestamp = headers["X-Slack-Request-Timestamp"]
    if abs(time.time() - int(timestamp)) > 60 * 5:
        return False  # replay protection
    base = b"v0:" + timestamp.encode() + b":" + raw_body
    expected = "v0=" + hmac.new(
        signing_secret.encode(), base, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, headers["X-Slack-Signature"])

Rule two: compare in constant time

expected === received returns as soon as the first character differs. Over many requests, the response time leaks how many leading characters an attacker guessed correctly. Use crypto.timingSafeEqual in Node.js (it throws on unequal lengths, so check length first) and hmac.compare_digest in Python. The hash comparator demonstrates the difference.

Rule three: check the timestamp

A valid signed request stays valid forever unless you check its age. An attacker who captures one invoice.paid event can replay it at will. Stripe, Slack and Standard Webhooks include the timestamp *inside* the signed content precisely so it cannot be altered; reject anything older than about five minutes. GitHub and Shopify do not sign a timestamp, so record the delivery ID header (X-GitHub-Delivery, X-Shopify-Webhook-Id) and ignore duplicates.

Debugging a signature that never matches

Work through these in order — they cover nearly every case:

  1. Wrong secret. Test and live mode have different secrets, and every Stripe endpoint has its own. The secret is per endpoint, not per account.
  2. Parsed body. Log typeof body in your handler. If it is an object, a parser already ran.
  3. Encoding mismatch. Shopify sends Base64; GitHub and Stripe send hex. Comparing hex to Base64 never matches.
  4. Missing prefix. GitHub's header includes sha256=; Slack's includes v0=. Either strip it or add it to your expected value.
  5. Trailing newline in the secret. Secrets pasted into .env files or Kubernetes Secrets frequently pick up a \n. Kubernetes values must be Base64 encoded without one — the Kubernetes secret generator handles this.
  6. Middleware rewrote the body. Compression, body-size limits and some proxies alter bytes in transit.

To test the failure path deliberately, the Stripe and GitHub webhook sandbox produces correctly signed requests and, optionally, tampered ones. If your endpoint accepts a tampered request, it is not verifying the raw body.

Rotating a webhook secret

You cannot update the provider and your verifier at the same instant, so events in flight during a swap will fail unless you accept both secrets for a while. Generate the new secret with the webhook secret generator, deploy a verifier that accepts either, switch the provider, then remove the old one once no events verify against it.

Frequently asked questions

Why does my Stripe webhook signature verification fail?

Almost always because the body was parsed before verification. In Express, mount express.raw({ type: "application/json" }) on the webhook route before express.json() runs; in Next.js, read await request.text(). The second most common cause is using the secret from a different endpoint or from test mode.

Is checking a secret token in the URL as good as a signature?

No. A token in the URL is logged by proxies and load balancers, cannot be rotated without reconfiguring the sender, and says nothing about whether the body was modified. A signature proves both who sent the request and that the payload is intact.

What is the difference between X-Hub-Signature and X-Hub-Signature-256?

X-Hub-Signature is GitHub's legacy header using HMAC-SHA1. X-Hub-Signature-256 uses HMAC-SHA256 and is the one to verify. GitHub sends both for compatibility; ignore the SHA-1 header.

Should I return 401 or 400 when a signature is invalid?

Either works, as long as it is not a 2xx. The provider treats any non-2xx response as a failed delivery and retries, which is what you want if the failure was a transient misconfiguration on your side.

Try it in your browser

Every tool referenced above runs locally — nothing you paste is uploaded.

More guides