webhook-kit
v0.1.0
Published
Verify webhook signatures from Stripe, GitHub, Slack, Shopify, Telegram, Paddle, Twilio and Standard Webhooks. Zero dependencies, constant-time, runs on Node, Bun, Deno and edge runtimes.
Maintainers
Readme
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.
npm install webhook-kitimport { 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:
- Every scheme is different. Stripe signs
<timestamp>.<body>and hex-encodes it. Shopify signs the body and base64-encodes it. Slack signsv0:<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. - 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. ===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.- 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.
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]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.
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 });
});// 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 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 webhookfastify.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 symmetricv1is 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:cryptoand 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 outputLicense
MIT
