OneShot

Webhook Signature Verification Starter

Build Webhook Signature Verification Starter on node-typescript

Correct inbound webhook signature verification for Stripe, GitHub, Shopify and Svix/Resend behind one seam — raw bytes, constant-time comparison, and an honest account of which two of the four schemes actually bound replay — as a free build pack with 30 hermetic checks your own agent must turn green.

3 of 3 runs passedVerified on node-typescript: 3 of 3 runs passed on claude-sonnet-5.

Webhook signature verification looks like four lines of crypto.createHmac and is wrong in about six specific ways — verifying a re-serialised body instead of the raw bytes, comparing digests with ===, falling back to GitHub's legacy SHA-1 header, keeping only the first v1= during a Stripe secret rotation, treating Svix's whsec_ secret the way Stripe's is treated, and enforcing a replay window off a timestamp header that isn't inside the signature. This pack has your own coding agent build one verifier for Stripe, GitHub, Shopify and Svix/Resend behind a single function, then proves it against 30 hermetic checks that run offline in under a minute. The execution receipt on this page is the published proof; the better one is the suite itself, which ships in the pack and needs no accounts, no keys and no Docker to run.

Example use cases

  • An indie SaaS adding its first Stripe webhook. The endpoint works in stripe listen and 401s the first time a customer with a non-ASCII name pays, because the handler parses the body before verifying and the JSON round-trip is only byte-identical for simple payloads. The raw-body-vs-parsed-body check is that bug, caught before deploy.
  • A GitHub App receiving push and pull_request events. The obvious implementation reads whichever signature header is present, which quietly accepts SHA-1. GitHub still sends it "only for legacy purposes"; a verifier that falls back to it has downgraded itself. github-sha1-not-accepted is a four-line fix and a check that stops it coming back.
  • A Shopify app developer who copy-pasted a working GitHub verifier. Shopify's digest is base64, GitHub's is hex. Every delivery 401s, the secret looks wrong, and an hour goes into the wrong hypothesis.
  • A team on Resend (or Clerk, Brex, Lithic, Loops — anything on Svix) that already has Stripe working. Both secrets start with whsec_. Stripe's is the HMAC key verbatim; Svix's is base64 that must be decoded first. Reusing the Stripe code rejects 100% of genuine deliveries while looking completely reasonable.
  • Anyone reviewing someone else's webhook handler. The 30 check names are the review checklist, and running them takes less time than reading the diff.
  • Anyone evaluating OneShot. This is a complete pack — same layout, same hermetic verify, same receipt contract as the paid ones — that costs nothing and takes a minute to run.

Scale envelope

Derived from the pinned stack — a node:crypto HMAC and a byte comparison — not from a benchmark. Nothing here is load-tested; the suite proves correctness, not throughput, and ACCEPTANCE.md says so.

  • Per request the module does exactly one thing: one HMAC-SHA256 over the body, one constant-time comparison, and for two of the four providers one integer subtraction. No I/O, no allocation beyond the body itself, no shared state, nothing to warm up. It is not the bottleneck in any webhook endpoint, and it does not need to be tuned.
  • The realistic bound is your framework's request concurrency, exactly as it was before you added this.
  • First ceiling: body size, not rate. Both raw-body helpers buffer the whole body in memory, because an HMAC cannot be checked until the last byte has arrived — that is a property of the construction, not a shortcut. At typical webhook payload sizes (single-digit kilobytes) this is irrelevant. If you receive multi-megabyte bodies, cap the size before buffering, or hash the stream incrementally and compare at the end. Either way, reject oversized bodies at the edge; an unbounded buffer on an unauthenticated endpoint is a denial-of-service surface that no signature check can close.
  • Second ceiling — and the real one: there is no second webhook problem solved here. The module has no state, so it scales trivially and stops being the interesting question immediately. The next question is deduplication, and that one needs a database.
  • Upgrade path: add a size cap at the edge; add a fifth provider (one file, one entry in two records); then, when duplicates or ordering start mattering, move to a durable pipeline — see below.

What it comfortably covers as shipped: any application receiving webhooks from one to four of these providers, at any volume its own framework can serve. What it isn't: a webhook pipeline. It is the front door.

What you get

Features:

  • verifyWebhook() — one entry point, four providers, throws on every failure path (there is deliberately no boolean-returning variant)
  • Four verifier modules, each written from the provider's current documentation and each citing it: stripe.ts, github.ts, shopify.ts, svix.ts
  • SignatureError with five exact codes (missing_header, malformed_header, bad_signature, timestamp_out_of_tolerance, unknown_provider) — asserted by the suite, so they are contract rather than diagnostics
  • Stripe secret rotation handled: multiple v1= signatures in one header, any match passes — the failure that shows up once a year and looks like an outage
  • Stripe v0 downgrade refused explicitly, per Stripe's own instruction
  • GitHub SHA-1 fallback refused explicitly — a missing X-Hub-Signature-256 is a 401, not a reason to check the legacy header
  • Svix whsec_ secret base64-decoded, and the whsec_-prefix collision with Stripe's differently-handled secret called out and tested
  • rawBodyFromRequest() / rawBodyFromNodeRequest() — the two shapes that cover Next.js, Hono, Remix, SvelteKit, Bun, Deno, Workers, Express, Fastify and plain node:http
  • Case-insensitive header lookup across Headers, lowercase objects and mixed-case objects (HTTP/2 vs HTTP/1.1 through a proxy)
  • SCHEMES table: headers, digest encoding, secret encoding, whether a timestamp is signed, default tolerance, and the docs URL each was verified against
  • verify.sh — 30 checks, offline, no accounts, no keys, no Docker, under a minute
  • GO-LIVE-CHECKLIST.md — the handful of steps a human must do in each provider's dashboard, and the secret-handling rules

Screens/pages: none. This pack has no UI surface. It is a module you import from your own route handler; the pack ships the handler pattern as a snippet in ARCHITECTURE D2, not as an application.

How it works

  1. 01

    Buy the pack

    Instant download: PRD, architecture decisions, task graph, acceptance tests, scaffold, and per-phase prompts.

  2. 02

    Feed it to your agent

    Claude Code or Cursor builds inside the pinned scaffold — no context needed beyond the pack itself.

  3. 03

    Verify

    Run the pack's acceptance script. It checks the same things our verification harness checked.

node-typescript versions lock

node24.18.1
typescript6.0.3
@types/node24.3.0
stripe-api-version2026-06-24.dahlia
github-rest-api-version2022-11-28
shopify-admin-api-version2026-07

Prescribed services

Disclosure: some links on this page are affiliate links. We may earn a commission if you sign up through them, at no extra cost to you. We only link to services the pack actually verified against.