Skip to content

Repository files navigation

webhook-kit

Verify incoming webhooks from Stripe, GitHub, Slack, Shopify, Telegram, Paddle, Twilio and Standard Webhooks — with one API. Zero runtime dependencies, constant-time comparison, and the same code runs on Node, Bun, Deno, Cloudflare Workers and Vercel Edge.

CI npm install size license

npm install webhook-kit
import { verifyRequest, stripe } from "webhook-kit";

const result = await verifyRequest(request, {
  provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
});

if (!result.ok) {
  return new Response(result.error.code, { status: result.error.status });
}

// Signature verified, timestamp inside the replay window, body parsed.
console.log(result.payload, result.eventId);

Why this exists

Receiving a webhook securely is four separate problems, and every provider solves them differently:

  1. Every scheme is different. Stripe signs <timestamp>.<body> and hex-encodes it. Shopify signs the body and base64-encodes it. Slack signs v0:<timestamp>:<body>. Twilio signs the URL with SHA-1. So each integration means reading another spec and writing another 40 lines you cannot easily test.
  2. The raw body trap. Your framework parses JSON before your handler runs, and JSON.stringify(parsed) is not byte-identical to what was signed. This is the single most common cause of "verification works locally and fails in production". See docs/raw-body.md.
  3. === leaks the signature. String equality returns early on the first differing byte, so response timing reveals how many leading bytes matched. Given enough requests that recovers a valid signature. Most hand-rolled implementations get this wrong.
  4. A valid signature is not a fresh one. Without a timestamp check and a record of what you have already processed, a captured request can be replayed, and provider retries get handled twice.

This package does all four, the same way, for eight providers.

A longer write-up of where the eight schemes diverge, and which of those divergences are security-relevant: Eight webhook providers, eight different ways to sign a request.

Supported providers

Provider Header(s) Algorithm Encoding Timestamp check Delivery id
stripe Stripe-Signature HMAC-SHA256 hex ✅ 300s body id
github X-Hub-Signature-256 HMAC-SHA256 hex X-GitHub-Delivery
shopify X-Shopify-Hmac-SHA256 HMAC-SHA256 base64 X-Shopify-Webhook-Id
slack X-Slack-Signature + X-Slack-Request-Timestamp HMAC-SHA256 hex ✅ 300s body event_id
standardWebhooks webhook-id / -timestamp / -signature HMAC-SHA256 base64 ✅ 300s webhook-id
paddle Paddle-Signature HMAC-SHA256 hex ✅ 5s body event_id
twilio X-Twilio-Signature HMAC-SHA1 base64 body MessageSid / CallSid
telegram X-Telegram-Bot-Api-Secret-Token shared secret body update_id

standardWebhooks covers every service that implements Standard Webhooks — Svix, Clerk, Resend and others.

Every default here comes from the provider's own documentation rather than a round number that felt reasonable: Paddle's 5-second window really is 5 seconds, and Slack rejects clock skew in both directions while Stripe only rejects stale deliveries. docs/providers.md documents each scheme and links its spec.

How a request flows

flowchart TD
    A[Raw body + headers] --> B[Normalize headers<br/>case-insensitively]
    B --> C{Required headers<br/>present?}
    C -- no --> R1[missing_header · 400]
    C -- yes --> D{Header parses per spec?}
    D -- no --> R2[malformed_header · 400]
    D -- yes --> E[Recompute HMAC<br/>over bytes, not strings]
    E --> F{Constant-time match?<br/>double-HMAC blinding}
    F -- no --> R3[invalid_signature · 401]
    F -- yes --> G{Timestamp inside<br/>the window?}
    G -- no --> R4[timestamp_out_of_tolerance · 401]
    G -- yes --> H{Delivery already seen?<br/>optional store}
    H -- yes --> R5[replayed · 409]
    H -- no --> I[ok: payload, eventId, timestamp]
Loading

The order matters. The timestamp is only trusted after the signature proves the provider set it — checking freshness first would let an attacker pick the timestamp.

Framework recipes

The only thing that changes between frameworks is how you get hold of the unparsed body.

Hono, Cloudflare Workers, Deno, Bun, Vercel Edge

Anything with a Web Request needs no special handling:

import { verifyRequest, github } from "webhook-kit";

app.post("/hooks/github", async (c) => {
  const result = await verifyRequest(c.req.raw, {
    provider: github({ secret: c.env.GITHUB_WEBHOOK_SECRET }),
  });
  if (!result.ok) return c.text(result.error.code, result.error.status);

  return c.json({ received: result.eventId });
});
Next.js App Router
// app/api/webhooks/stripe/route.ts
import { verifyWebhook, stripe } from "webhook-kit";

export async function POST(request: Request) {
  const result = await verifyWebhook(
    { headers: request.headers, body: new Uint8Array(await request.arrayBuffer()) },
    { provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }) },
  );

  if (!result.ok) {
    return new Response(result.error.code, { status: result.error.status });
  }
  return Response.json({ received: true });
}

request.text() works too, but arrayBuffer() avoids a string round-trip entirely.

Express

Express is where most people get burned. express.json() replaces the body with a parsed object, and the original bytes are gone — so the raw parser must be mounted on the webhook route specifically, before any global JSON parser:

import express from "express";
import { verifyWebhook, stripe } from "webhook-kit";

const app = express();
const provider = stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! });

// Note: express.raw() on this route, mounted BEFORE express.json().
app.post("/hooks/stripe", express.raw({ type: "*/*" }), async (req, res) => {
  const result = await verifyWebhook({ headers: req.headers, body: req.body }, { provider });

  if (!result.ok) return res.status(result.error.status).send(result.error.code);
  res.json({ received: true });
});

app.use(express.json()); // every other route, after the webhook
Fastify
fastify.addContentTypeParser(
  "application/json",
  { parseAs: "buffer" },
  (_req, body, done) => done(null, body),
);

fastify.post("/hooks/shopify", async (request, reply) => {
  const result = await verifyWebhook(
    { headers: request.headers, body: request.body as Uint8Array },
    { provider: shopify({ clientSecret: process.env.SHOPIFY_CLIENT_SECRET! }) },
  );

  if (!result.ok) return reply.code(result.error.status).send(result.error.code);
  return { received: true };
});

Replay protection and idempotency

Pass a store and a delivery is accepted exactly once:

import { verifyWebhook, stripe, memoryStore } from "webhook-kit";

const store = memoryStore(); // single process only — see below

const result = await verifyWebhook(request, {
  provider: stripe({ secret }),
  store,
});

if (!result.ok && result.error.code === "replayed") {
  return new Response("already handled", { status: 200 });
}

SeenStore is deliberately one method, so anything atomic can back it without an adapter package:

import type { SeenStore } from "webhook-kit";

const redisStore: SeenStore = {
  async add(key, ttlSeconds) {
    // SET NX returns null when the key already existed.
    return (await redis.set(key, "1", { NX: true, EX: ttlSeconds })) !== null;
  },
};

memoryStore() is per-process and lost on restart, which means one duplicate per replica behind a load balancer. Use it for a single long-lived server and for tests; use Redis, a unique index, or Cloudflare KV for anything else. Full discussion in docs/replay-and-idempotency.md.

Error codes

result.error.code is stable and machine-readable, and result.error.status is the HTTP status we suggest returning.

Code Status Meaning
missing_header 400 A header the scheme requires was absent
malformed_header 400 Present but did not parse per the spec
invalid_signature 401 Signature did not match the body
timestamp_out_of_tolerance 401 Genuine signature, outside the replay window
replayed 409 This delivery was already processed
invalid_payload 400 Genuine signature, body was not the expected format
invalid_secret 500 Your secret is missing or malformed
missing_url 500 Provider signs the URL and none was supplied

The two 5xx codes are the point of the table. A bad secret is your deployment's fault, not the sender's, and answering 401 would tell the provider to stop retrying a delivery that would succeed the moment you fix the config.

Genuine defects — a Redis outage, a runtime without Web Crypto — are thrown, not returned. A failed signature is an ordinary outcome for a public endpoint; an unavailable database is not, and quietly reporting it as invalid_signature would hide an incident.

Design notes

Constant-time comparison. Node's crypto.timingSafeEqual does not exist in Web Crypto, so it is unavailable on edge runtimes — and it throws outright when lengths differ, which is exactly what a truncated signature produces. Instead both operands are HMAC'd under a key generated fresh for that single call and the 32-byte digests compared (double-HMAC blinding). An attacker cannot steer the digest, so no timing information about the original values survives, and unequal lengths need no special case.

Byte-level joining. Schemes that prefix the body with a timestamp are specified over bytes. Building `${t}.${body}` as a JavaScript string round-trips the payload through UTF-16, which silently rewrites malformed UTF-8 into U+FFFD and breaks the signature. This package concatenates Uint8Arrays, and there is a test for it.

Keys imported once. crypto.subtle.importKey is not free and a webhook endpoint runs it on every request, so each provider memoizes its CryptoKey. A transient import failure clears the cache rather than poisoning the provider with a permanently rejected promise — also tested.

Zero runtime dependencies, on purpose. A package whose whole job is verifying signatures should not widen your supply chain to do it. Nothing is imported from node:*, which is why CI runs the built output on Bun and Deno as well as Node 20/22/24.

Benchmarks

npm run bench, on an Apple M5 Pro (15 cores), Node v20.20.2, single-threaded:

ops/sec mean
GitHub, 1 KB body, provider reused 24,910 0.040 ms
GitHub, 1 KB body, provider rebuilt per request 20,936 0.048 ms
Stripe, 1 KB body, incl. timestamp check 20,722 0.048 ms
GitHub, 64 KB body 7,558 0.132 ms
GitHub, 1 MB body 640 1.562 ms

Two things are worth reading out of this, and neither is "it's fast":

Reuse your provider. Building it once and reusing it is ~1.19× faster than constructing it per request, which is the memoized CryptoKey earning its keep. In absolute terms both are ~0.04 ms and utterly dominated by your network round trip — so this matters for tidiness, not for throughput.

Cost is linear in body size, as HMAC must be. At 1 MB you are paying ~1.6 ms, which is still small but no longer free.

These numbers describe one machine and are not a comparison against any other library. Reproduce with bench/verify.bench.ts.

Known limitations

Stated plainly, because these are the things worth knowing before you depend on it:

  • Telegram's scheme is not a signature. Telegram echoes a shared secret in a header and signs nothing. It cannot detect a modified body — anyone who learns the token can send you arbitrary updates. Treat it as a bearer credential. It is included because it is what Telegram offers, and because === is the wrong way to check it.
  • Asymmetric signatures are not implemented. Standard Webhooks' v1a (ed25519) entries are skipped, not verified. Only symmetric v1 is checked.
  • Twilio needs the exact public URL you configured in the console. Behind a proxy or tunnel that rewrites host or scheme, a reconstructed URL will not match.
  • Paddle's 5-second default will reject on modest clock skew. It matches Paddle's official SDKs so the library is not quietly weaker than the spec; raise it with paddle({ secretKey, tolerance: 300 }) if that is too tight for your deployment.
  • Test vectors are cross-implementation, not captured traffic. Signatures in the test suite are generated with Node's node:crypto and verified with the package's Web Crypto code, so two independent implementations of each spec must agree. That is stronger than self-consistency, but it is not the same as replaying real recorded deliveries from each provider.
  • The constant-time claim is structural, not measured. Double-HMAC blinding is the standard mitigation and the comparison never exits early, but this has not been validated with statistical timing analysis.
  • No middleware yet. Framework integration is the recipes above; drop-in middleware for Express and Hono is planned for 0.2.

Development

npm install
npm test              # 102 tests, including property-based and malformed-input suites
npm run test:coverage # thresholds: 95% lines, 90% branches
npm run typecheck
npm run lint
npm run build
node test/smoke.mjs   # cross-runtime check against the built output

License

MIT

About

Verify webhook signatures from Stripe, GitHub, Slack, Shopify, Paddle, Twilio, Telegram and Standard Webhooks with one API. Zero dependencies, constant-time, runs on Node, Bun, Deno and edge runtimes.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages