DEV Community

Gerald Mitchell
Gerald Mitchell

Posted on

Verify webhook signatures correctly: Stripe, Shopify and GitHub side by side

Every major provider signs webhooks with HMAC, and almost every integration bug comes from the same three mistakes: hashing a re-serialized body, comparing strings unsafely, and processing the same event twice.

1. Hash the raw bytes, never parsed JSON

JSON.parse → JSON.stringify changes whitespace and key order, so the HMAC won't match. Capture the raw body before any JSON middleware.

// Express: mount this BEFORE app.use(express.json())
app.post("/webhooks/stripe", express.raw({ type: "application/json" }), handler);
Enter fullscreen mode Exit fullscreen mode

Next.js App Router: use await req.text(), not req.json().

2. Each provider, one function

import crypto from "node:crypto";
const safeEq = (a, b) => a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));

// Stripe: header "t=...,v1=..."; signed payload = `${t}.${raw}`, hex
export function stripe(raw, header, secret, tolerance = 300) {
  const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > tolerance) return false;
  const sig = crypto.createHmac("sha256", secret).update(`${parts.t}.${raw}`).digest("hex");
  return safeEq(sig, parts.v1); // Stripe may send several v1= values during secret rotation; accept if any matches.
}

// Shopify: X-Shopify-Hmac-Sha256, base64 of HMAC over raw body
export const shopify = (raw, h, secret) =>
  safeEq(crypto.createHmac("sha256", secret).update(raw).digest("base64"), h);

// GitHub: X-Hub-Signature-256 = "sha256=" + hex
export const github = (raw, h, secret) =>
  safeEq("sha256=" + crypto.createHmac("sha256", secret).update(raw).digest("hex"), h);
Enter fullscreen mode Exit fullscreen mode

In production prefer the official SDK where one exists (stripe.webhooks.constructEvent), but knowing the shape helps you debug it.

3. Use a timing-safe comparison

sig === header leaks how many leading characters matched. crypto.timingSafeEqual (Node), hmac.compare_digest (Python) and subtle.ConstantTimeCompare (Go) don't. Check lengths first; timingSafeEqual throws on unequal lengths.

4. Reject replays

Stripe includes a timestamp in the signed payload, so enforce a window (5 minutes is the SDK default). For providers without one, rely on step 5.

5. Be idempotent

Providers retry on timeouts, so you will receive duplicates.

CREATE TABLE processed_events (id text PRIMARY KEY, received_at timestamptz DEFAULT now());
-- INSERT ... ON CONFLICT DO NOTHING; only act if a row was inserted
Enter fullscreen mode Exit fullscreen mode

Respond 2xx fast and move slow work to a queue, or the provider will time out and retry anyway.

Debug checklist

  • Using the endpoint's signing secret (whsec_...), not your API key?
  • Test-mode vs live-mode secret mixed up?
  • A proxy (Cloudflare, API Gateway) modifying or re-encoding the body?
  • Body read as a UTF-8 string when it contained non-UTF-8 bytes? Hash the Buffer.

Disclosure: I drafted this article with help from an AI assistant and checked the code and claims by hand.


I keep a one-page Webhook Signature Cheat Sheet (free) with the header names, encodings and gotchas for each provider. If you want the whole toolchain (a self-hosted relay that verifies and retries, AI coding-assistant skills for webhook work, and the CORS kit), there's a Webhook & CORS Fix Bundle.

Independent write-up by the author; not affiliated with or endorsed by Stripe, Shopify, GitHub, Gumroad or any other vendor mentioned.

Top comments (1)

Collapse
 
challan116ux profile image
challan116-ux •

Laying the four providers side by side makes the real lesson clear: the HMAC idea is identical, but the signed payload construction is not — Stripe signs timestamp plus raw body, Shopify signs the raw body alone and encodes in base64, GitHub prefixes the hex digest. Most 'my verification fails' bugs I have seen were a correct algorithm applied to another provider's construction, or to a re-serialized body. Keeping one small verifier per provider, tested against a captured real delivery for each, beats one clever generic verifier every time.