How to Verify Webhook Signatures and Handle Retries Safely (2026)
This guide is for developers who receive webhooks from Stripe, GitHub or any provider that follows the Standard Webhooks spec, and want a handler that rejects forged requests, survives retries and duplicates, and never processes the same payment twice. By the end you will have HMAC SHA-256 verification in Node and Python, a raw-body setup that works in Express and FastAPI, timestamp checks against replay, and a Postgres inbox table that turns retries into harmless no-ops. Everything below was run locally on 10 November 2026 with Node 24.20, Express 5.3.0, stripe-node 23.1.0, standardwebhooks 1.1.1 (npm) and 1.1.0 (PyPI), Python 3.12, FastAPI 0.162.2, stripe-python 16.1.0, psycopg 3.3.8 and PostgreSQL 16, using generated test secrets only. You need one of those runtimes and a Postgres you can create a table in. For the why behind each step (delivery guarantees, ordering, retry schedules), see the companion post on webhooks best practices in 2026.
Step 1: Get the raw request body in Express and FastAPI
Every signature scheme here is an HMAC over bytes. If a framework parses the JSON and you re-serialise it, the bytes change (key order, whitespace, a trailing newline) and verification fails. The Standard Webhooks spec calls this "a very common failure mode", and Stripe's docs say any manipulation of the raw body breaks verification.
In Express 5, mount express.raw() on the webhook route and register it before any global express.json():
// server.mjs: Express 5.3 webhook receiver (Node 24)
import express from "express";
import { verifyGitHub, verifyStandard } from "./verify.mjs";
const app = express();
// 1. Webhook routes get the raw bytes. Register BEFORE express.json().
app.post(
"/webhooks/github",
express.raw({ type: "application/json", limit: "1mb" }),
(req, res) => {
// req.body is a Buffer here, exactly as GitHub sent it
if (!verifyGitHub(req.body, req.get("x-hub-signature-256"), process.env.GITHUB_WEBHOOK_SECRET)) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(req.body); // parse only after verifying
console.log("github", req.get("x-github-delivery"), req.get("x-github-event"), event.action);
res.status(202).send("queued");
}
);
app.post(
"/webhooks/standard",
express.raw({ type: "application/json", limit: "1mb" }),
(req, res) => {
if (!verifyStandard(req.body, req.headers, process.env.STANDARD_WEBHOOK_SECRET)) {
return res.status(401).send("bad signature");
}
console.log("standard", req.headers["webhook-id"], JSON.parse(req.body).type);
res.status(202).send("queued");
}
);
// 2. Everything else can have parsed JSON
app.use(express.json());
app.listen(3000, () => console.log("listening on :3000"));
One Express 5 detail bit me while testing: express.raw({ type: "application/json" }) only reads the body when the Content-Type matches. With any other content type, req.body is undefined, not an empty object. GitHub lets you configure a webhook as application/x-www-form-urlencoded, so either set it to application/json in the webhook settings or widen the type option.
In FastAPI, read await request.body() and do not declare a Pydantic model parameter for the webhook route, because that makes FastAPI parse the body for you:
# fastapi_raw.py: the only line that matters
from fastapi import FastAPI, Request
app = FastAPI()
@app.post("/webhooks/github")
async def github(request: Request):
raw: bytes = await request.body() # exact bytes; verify these
# do NOT call await request.json() or use a Pydantic body param before verifying
return {"len": len(raw)}
The same applies to anything in front of your app. A proxy, API gateway or serverless adapter that decodes and re-encodes JSON will break signatures. For AWS API Gateway with Lambda, Stripe's signature troubleshooting page shows a mapping template that passes the raw body through as rawBody.
Step 2: Verify GitHub's X-Hub-Signature-256 in Node and Python
GitHub sends X-Hub-Signature-256: sha256=<hex>, an HMAC SHA-256 of the raw body keyed with your webhook secret, per its validation docs. The legacy X-Hub-Signature header uses SHA-1 and exists for compatibility only; ignore it. Here is the dependency-free Node verifier, which also handles Standard Webhooks (Step 4):
// verify.mjs: HMAC SHA-256 webhook verification, Node 24, no dependencies
import { createHmac, timingSafeEqual } from "node:crypto";
// Constant-time compare that never throws on length mismatch
function safeEqual(a, b) {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
return ab.length === bb.length && timingSafeEqual(ab, bb);
}
// GitHub: X-Hub-Signature-256: sha256=<hex HMAC of raw body>
export function verifyGitHub(rawBody, header, secret) {
if (!Buffer.isBuffer(rawBody)) return false; // body was parsed or never read
if (!header || !header.startsWith("sha256=")) return false;
const expected =
"sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
return safeEqual(expected, header);
}
// Standard Webhooks: sign "id.timestamp.body" with base64-decoded whsec_ key
export function verifyStandard(rawBody, headers, secret, toleranceSec = 300) {
if (!Buffer.isBuffer(rawBody)) return false;
const id = headers["webhook-id"];
const ts = headers["webhook-timestamp"];
const sigHeader = headers["webhook-signature"];
if (!id || !ts || !sigHeader) return false;
const now = Math.floor(Date.now() / 1000);
const t = Number.parseInt(ts, 10);
if (!Number.isFinite(t) || Math.abs(now - t) > toleranceSec) return false;
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
const expected = createHmac("sha256", key)
.update(`${id}.${ts}.`)
.update(rawBody)
.digest("base64");
// Header can carry several space-separated signatures during key rotation
return sigHeader
.split(" ")
.map((s) => s.split(","))
.some(([version, sig]) => version === "v1" && sig && safeEqual(sig, expected));
}
crypto.timingSafeEqual throws if the two buffers differ in length, so the safeEqual wrapper checks length first. Without it, a garbage header becomes a 500 instead of a 401, and a 500 invites the sender to retry.
GitHub publishes a test vector, so check your code against it before trusting it:
// check.mjs: GitHub's documented test vector
import { verifyGitHub } from "./verify.mjs";
console.log(verifyGitHub(
Buffer.from("Hello, World!"),
"sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17",
"It's a Secret to Everybody"
)); // true
The Python equivalent uses hmac.compare_digest, which is constant time and handles unequal lengths:
# github_verify.py: Python 3.12, standard library only
import hashlib
import hmac
def verify_github(raw: bytes, header: str | None, secret: str) -> bool:
if not header or not header.startswith("sha256="):
return False
expected = "sha256=" + hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
assert verify_github(
b"Hello, World!",
"sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17",
"It's a Secret to Everybody",
)
GitHub does not sign a timestamp, so there is no freshness check to do. Replay protection comes from deduplicating on X-GitHub-Delivery in Step 5; GitHub's best practices say a redelivery keeps the same value.
Step 3: Verify Stripe webhooks with the official library
Stripe's header looks like t=1492774577,v1=<hex>,v0=<hex>. The signed payload is timestamp + "." + raw body, keyed with the endpoint's whsec_... string used as-is, and only the v1 scheme counts (Stripe webhook docs). You can write that by hand, but the libraries also check the timestamp with a default tolerance of five minutes, so use them.
// stripe-route.mjs: Stripe webhook route, stripe-node 23.1.0 + Express 5.3
import express from "express";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); // sk_test_... in dev
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET; // whsec_... for THIS endpoint
const app = express();
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
// Buffer in, verified Event out. Default tolerance is 300 seconds.
event = stripe.webhooks.constructEvent(req.body, req.get("stripe-signature"), endpointSecret);
} catch (err) {
console.warn("stripe signature failed:", err.message);
return res.status(400).send("bad signature");
}
console.log("stripe", event.id, event.type);
res.json({ received: true }); // ack first; process from the inbox later
});
app.use(express.json());
app.listen(4242, () => console.log("listening on :4242"));
When I sent a correctly signed body with one extra trailing newline, the library rejected it with the error everyone searches for:
stripe signature failed: No signatures found matching the expected signature for payload. Are you passing the raw request body you received from Stripe?
Passing a parsed object instead of a Buffer gives a different, more helpful error ("Webhook payload must be provided as a string or a Buffer"). On Cloudflare Workers or Deno, use stripe.webhooks.constructEventAsync, which takes the same arguments and works with the Web Crypto API. In Python the call is stripe.Webhook.construct_event(raw, sig_header, secret, tolerance=300); it raises stripe.SignatureVerificationError on a bad signature or an expired timestamp. Step 5 uses it.
Two Stripe specifics cause most mismatches. The secret printed by stripe listen is different from the one for a Dashboard endpoint, and test and live mode endpoints have different secrets (Stripe troubleshooting). And never pass a tolerance of 0: Stripe's docs say it disables the recency check entirely.
Step 4: Verify Standard Webhooks signatures (webhook-signature header)
Standard Webhooks sends three headers: webhook-id, webhook-timestamp (Unix seconds) and webhook-signature (space-separated v1,<base64> entries). The signed content is id.timestamp.body, and the secret is whsec_ followed by base64, which you must decode to bytes before using it as the HMAC key (spec). The verifyStandard function in Step 2 does all of that. Svix sends the same scheme under svix-id, svix-timestamp and svix-signature by default (Svix docs).
The official libraries are smaller still, and both default to a five-minute tolerance in each direction:
# sw_verify.py: standardwebhooks 1.1.0 (pip install standardwebhooks==1.1.0)
from standardwebhooks.webhooks import Webhook, WebhookVerificationError
wh = Webhook("whsec_...") # from the provider's dashboard
def verify(raw: bytes, headers: dict[str, str]) -> dict | None:
try:
return wh.verify(raw, headers) # returns parsed JSON on success
except WebhookVerificationError as exc:
# "No matching signature found", "Message timestamp too old", ...
print("rejected:", exc)
return None
// sw_verify.mjs: standardwebhooks 1.1.1 (npm i standardwebhooks@1.1.1)
import { Webhook } from "standardwebhooks";
const wh = new Webhook(process.env.STANDARD_WEBHOOK_SECRET);
// throws WebhookVerificationError; returns the parsed payload on success
export const verify = (rawBody, headers) => wh.verify(rawBody, headers);
Header lookup is case-insensitive in both libraries. Accepting any matching v1 entry is what lets the sender rotate secrets without downtime, so do not "simplify" the loop to check only the first signature.
Step 5: Make the handler idempotent with a Postgres dedupe table
Retries and duplicates are normal: Stripe retries for up to three days in live mode and says the same event can arrive more than once. The fix is an inbox table with a primary key on the provider's event ID. The insert either creates a row (new event) or does nothing (duplicate), atomically, even with ten concurrent deliveries.
-- schema.sql: webhook inbox / dedupe table (PostgreSQL 16+)
CREATE TABLE IF NOT EXISTS webhook_inbox (
provider text NOT NULL, -- 'stripe', 'github', ...
event_id text NOT NULL, -- evt_..., X-GitHub-Delivery, webhook-id
event_type text NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'done', 'failed')),
attempts int NOT NULL DEFAULT 0,
last_error text,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
PRIMARY KEY (provider, event_id)
);
CREATE INDEX IF NOT EXISTS webhook_inbox_pending
ON webhook_inbox (received_at) WHERE status = 'pending';
The FastAPI receiver verifies, inserts, and answers. It returns success for duplicates too, because an error would make the provider retry something you already have:
# app.py: FastAPI 0.162 webhook receiver with a Postgres inbox
import hashlib
import hmac
import json
import os
import psycopg
import stripe
from fastapi import FastAPI, HTTPException, Request, Response
app = FastAPI()
DB_URL = os.environ["DATABASE_URL"]
GITHUB_SECRET = os.environ["GITHUB_WEBHOOK_SECRET"].encode()
STRIPE_SECRET = os.environ["STRIPE_WEBHOOK_SECRET"] # whsec_...
def store(provider: str, event_id: str, event_type: str, raw: bytes) -> bool:
"""Insert into the inbox. Returns False if we have seen this event before."""
with psycopg.connect(DB_URL) as conn:
row = conn.execute(
"""INSERT INTO webhook_inbox (provider, event_id, event_type, payload)
VALUES (%s, %s, %s, %s::jsonb)
ON CONFLICT (provider, event_id) DO NOTHING
RETURNING event_id""",
(provider, event_id, event_type, raw.decode("utf-8")),
).fetchone()
return row is not None
@app.post("/webhooks/github", status_code=202)
async def github(request: Request):
raw = await request.body() # bytes, exactly as sent; do NOT use request.json() first
header = request.headers.get("x-hub-signature-256", "")
expected = "sha256=" + hmac.new(GITHUB_SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, header):
raise HTTPException(status_code=401, detail="bad signature")
delivery = request.headers["x-github-delivery"] # same value on redelivery
new = store("github", delivery, request.headers.get("x-github-event", "?"), raw)
return {"duplicate": not new}
@app.post("/webhooks/stripe")
async def stripe_webhook(request: Request):
raw = await request.body()
try:
event = stripe.Webhook.construct_event(
raw, request.headers.get("stripe-signature", ""), STRIPE_SECRET, tolerance=300
)
except (ValueError, stripe.SignatureVerificationError):
raise HTTPException(status_code=400, detail="bad signature")
new = store("stripe", event["id"], event["type"], raw)
return Response(status_code=200, content=json.dumps({"duplicate": not new}))
Sending the same signed GitHub delivery twice, then a tampered body, then a Stripe event twice and a Stripe event signed 400 seconds ago produced:
gh1 {'duplicate': False}
gh2 {'duplicate': True}
gh bad 401
stripe1 200 {"duplicate": false}
stripe2 200 {"duplicate": true}
stripe old 400 {"detail":"bad signature"}
In production, use a connection pool (psycopg_pool) rather than a connection per request. Stripe also notes that it occasionally creates two distinct Event objects for one change; for money-moving logic, make the business write idempotent as well, for example an upsert keyed on the invoice ID.
Step 6: Acknowledge fast and process in a worker
GitHub fails a delivery that takes more than 10 seconds, and Shopify allows five (Shopify docs). Do the real work after the 2xx. The inbox is already a queue, so a worker with FOR UPDATE SKIP LOCKED can drain it safely with several copies running:
# worker.py: drain the inbox; safe to run several copies
import os
import time
import psycopg
DB_URL = os.environ["DATABASE_URL"]
def handle(event_type: str, payload: dict) -> None:
# Your business logic. Make it idempotent too (upserts, not blind inserts).
print("handling", event_type, payload.get("id"))
def run_once(conn) -> bool:
with conn.transaction():
row = conn.execute(
"""SELECT provider, event_id, event_type, payload
FROM webhook_inbox
WHERE status = 'pending' AND attempts < 10
ORDER BY received_at
LIMIT 1
FOR UPDATE SKIP LOCKED"""
).fetchone()
if row is None:
return False
provider, event_id, event_type, payload = row
try:
with conn.transaction(): # savepoint: roll back only the handler
handle(event_type, payload)
conn.execute(
"""UPDATE webhook_inbox SET status = 'done', processed_at = now(),
attempts = attempts + 1
WHERE provider = %s AND event_id = %s""",
(provider, event_id),
)
except Exception as exc: # keep the row, record the error, retry later
conn.execute(
"""UPDATE webhook_inbox SET attempts = attempts + 1, last_error = %s,
status = CASE WHEN attempts + 1 >= 10 THEN 'failed' ELSE 'pending' END
WHERE provider = %s AND event_id = %s""",
(str(exc), provider, event_id),
)
return True
if __name__ == "__main__":
with psycopg.connect(DB_URL, autocommit=True) as conn:
while True:
if not run_once(conn):
time.sleep(1)
When I made handle raise RuntimeError("downstream 503"), the row stayed pending with attempts = 1 and the error recorded, which is what you want: the provider sees success, and your retry is under your control. If handlers write to your own database, do the business write inside the savepoint so it commits atomically with the done update. Because events can arrive out of order, have the handler fetch the current object from the provider's API (or compare an updated field) instead of applying the payload blindly.
Step 7: Test signed requests locally
You do not need a provider to test verification. Generate a secret, sign with openssl, and send with curl:
# GitHub-style request against the Express server from Step 1
export GITHUB_WEBHOOK_SECRET="$(openssl rand -hex 32)"
BODY='{"action":"opened","number":1}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$GITHUB_WEBHOOK_SECRET" -hex | sed 's/^.* //')"
curl -s -i -X POST localhost:3000/webhooks/github \
-H 'Content-Type: application/json' \
-H "X-Hub-Signature-256: $SIG" \
-H 'X-GitHub-Event: pull_request' \
-H 'X-GitHub-Delivery: 72d3162e-cc78-11e3-81ab-4c9367dc0958' \
--data "$BODY" | head -1
# HTTP/1.1 202 Accepted
# Standard Webhooks: key is the base64-decoded part after whsec_
export STANDARD_WEBHOOK_SECRET="whsec_$(openssl rand -base64 32)"
ID=msg_test_1; TS=$(date +%s)
BODY2='{"type":"invoice.paid","timestamp":"2026-11-10T09:00:00Z","data":{"id":"in_1"}}'
KEYHEX=$(printf '%s' "${STANDARD_WEBHOOK_SECRET#whsec_}" | openssl base64 -d -A | xxd -p -c 256)
SIG2=$(printf '%s' "$ID.$TS.$BODY2" | openssl dgst -sha256 -mac HMAC -macopt hexkey:$KEYHEX -binary | openssl base64 -A)
curl -s -i -X POST localhost:3000/webhooks/standard \
-H 'Content-Type: application/json' \
-H "webhook-id: $ID" -H "webhook-timestamp: $TS" -H "webhook-signature: v1,$SIG2" \
--data "$BODY2" | head -1
# HTTP/1.1 202 Accepted
Use printf '%s', not echo, because echo appends a newline that changes the HMAC. For Stripe, the CLI (v1.55.9 is the latest release) forwards real test-mode events: stripe listen --forward-to localhost:4242/webhooks/stripe prints a whsec_ secret for the session, and stripe trigger payment_intent.succeeded sends an event. To receive from a real provider on a laptop, a tunnel works; see Cloudflare Tunnel.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Stripe: "No signatures found matching the expected signature for payload" | Body parsed or re-serialised before verification, or wrong whsec_ secret |
Use express.raw() on the route before express.json(); check you are using the endpoint's secret, not the stripe listen one (or vice versa) |
| Stripe: "Webhook payload must be provided as a string or a Buffer" | req.body is a parsed object |
A global express.json() ran first; move the webhook route above it |
req.body is undefined in Express 5 |
Content-Type did not match express.raw({ type }) |
Set the GitHub webhook to application/json or widen type |
| Signature fails only in production | Proxy, gateway or Lambda adapter rewrote the body | Pass the raw body through (Stripe documents an API Gateway template); compare body length logged before and after the proxy |
| Standard Webhooks signature never matches | Used the whsec_... string as the key |
Strip whsec_ and base64-decode to bytes first (Stripe is the exception: it uses the string as-is) |
| "Message timestamp too old" or "Timestamp outside the tolerance zone" | Clock drift, or a queued request replayed late | Sync with NTP; do not raise the tolerance to hide drift, and never set it to 0 |
| Node handler returns 500 on bad requests | timingSafeEqual threw on unequal lengths |
Check lengths first, as safeEqual does |
| Same event processed twice | Dedupe window too short or check not atomic | Primary key on (provider, event_id) with ON CONFLICT DO NOTHING; keep rows at least as long as the retry window |
| GitHub events missing after an outage | GitHub does not retry automatically | Redeliver failed deliveries from the last 3 days via the UI or REST API |
| Locally signed curl request fails | echo added a newline, or hex vs base64 mix-up |
Use printf '%s'; GitHub and Stripe are hex, Standard Webhooks and Shopify are base64 |
FAQ
Why does my webhook signature not match?
Almost always because you are hashing different bytes from the ones the sender signed: the framework parsed the JSON, a proxy rewrote it, or a newline was added. The second most common cause is the wrong secret, such as a test-mode or CLI secret in production. Log the body length and the first few characters of the secret you load, then compare.
Should I return 4xx or 5xx when verification fails?
Return 400 or 401 for a bad signature and never process the payload. Return 5xx only for genuine server failures you want retried; a 5xx on a duplicate or a bad request just generates more retries.
How long should I keep webhook IDs for deduplication?
At least as long as the sender's retry window: three days for Stripe live mode, and longer if you also run manual replays. The Standard Webhooks spec's example of five minutes in Redis only covers fast retries; a Postgres table with a cleanup job is cheap and covers both.
Do I still need signature checks if I allowlist the sender's IPs?
Yes. Stripe recommends both, and GitHub publishes its ranges via GET /meta, but IP ranges change and do not prove the body was not altered. The signature is the actual control; the allowlist is defence in depth.
When a webhook integration breaks in production, the first thing I check is whether the bytes reaching the verifier are the bytes the provider sent: log the body length at the edge and in the handler, and the mismatch usually shows itself. The second is the provider's delivery log, which tells you whether you are failing on signature, on timeout, or not being called at all. In my own services the receiver does nothing but verify, insert and return; everything interesting happens in a worker I can retry, pause and replay. The official references are the Standard Webhooks spec, Stripe's webhook docs, GitHub's validation guide and Shopify's verification guide.
Related: Webhooks Best Practices (2026): Retries, Idempotency and Signing, How to Set Up Usage-Based Billing with Stripe Meters (2026) and How to Stop Secrets Leaking Into Git: Gitleaks, Pre-commit, CI (2026).
Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Implement SOC 2 Controls on AWS with Terraform (2026)
Step-by-step SOC 2 technical controls on AWS with Terraform: organization CloudTrail, AWS Config and a custom conformance pack, GuardDuty, Security Hub CSPM with CIS v5.0.0, Identity Center MFA, encryption defaults, AWS Backup vault lock, 400-day retention and evidence export.
18 minOct 20, 2026How to Implement Multi-Tenancy with Postgres Row-Level Security (2026)
Step-by-step Postgres row-level security for multi-tenant SaaS: tenant_id schema design, FORCE RLS and non-owner roles, policies using current_setting, SET LOCAL with PgBouncer transaction pooling, indexes, CI isolation tests and fixes for common errors.
13 minNov 10, 2026How to Harden Ubuntu 24.04 With an Ansible Playbook (2026)
Step-by-step Ansible playbook to harden Ubuntu 24.04: pinned ansible-core 2.21.6, inventory, SSH drop-in that beats 50-cloud-init.conf, ufw, unattended-upgrades, sudo, fail2ban, auditd, sysctl, --check --diff, ansible-lint and fixes for common errors.
13 min