Stop writing the same 200 lines of webhook plumbing
hooksentinel verifies, deduplicates, and fast-acks inbound webhooks from Stripe, GitHub, Shopify, and 6 other providers — in type-safe TypeScript, with zero runtime dependencies.
40 lines of hand-rolled verification, or 8 with hooksentinel
Both handle the same Stripe checkout webhook. Only one of them also covers timestamp tolerance mistakes, timing-safe comparison, and deduplication correctly by default.
import express from 'express';
import crypto from 'crypto';
const app = express();
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
const processedEvents = new Set<string>();
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'];
if (typeof sig !== 'string') {
return res.status(400).send('Missing signature');
}
const [tPart, v1Part] = sig.split(',');
const timestamp = tPart?.split('=')[1];
const expectedSig = v1Part?.split('=')[1];
if (!timestamp || !expectedSig) {
return res.status(400).send('Malformed signature');
}
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (age > 300) {
return res.status(400).send('Timestamp too old');
}
const signedPayload = `${timestamp}.${req.body.toString()}`;
const computed = crypto
.createHmac('sha256', endpointSecret)
.update(signedPayload)
.digest('hex');
const valid =
computed.length === expectedSig.length &&
crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(expectedSig));
if (!valid) {
return res.status(401).send('Invalid signature');
}
let event: { id: string; type: string; data: { object: { id: string } } };
try {
event = JSON.parse(req.body.toString());
} catch {
return res.status(400).send('Invalid JSON');
}
if (processedEvents.has(event.id)) {
return res.status(200).send('Already processed');
}
processedEvents.add(event.id);
res.status(200).send('OK');
if (event.type === 'checkout.session.completed') {
await fulfillOrder(event.data.object.id);
}
},
);import { createWebhookHandler, stripe } from '@hooksentinel/core';
import { toExpressHandler } from '@hooksentinel/core/express';
import { memoryStore } from '@hooksentinel/core/stores';
const webhook = createWebhookHandler({
provider: stripe({ secret: process.env.STRIPE_WEBHOOK_SECRET! }),
idempotency: memoryStore(),
onEvent: async (event) => {
if (event.type === 'checkout.session.completed') {
await fulfillOrder(event.data.object.id);
}
},
});
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
toExpressHandler(webhook),
);Why hooksentinel
Verify
Every request is checked against the provider's real signature scheme — HMAC or Ed25519 — before your code ever sees it. Fails closed by default.
Deduplicate
Providers deliver at least once, not exactly once. hooksentinel tracks event IDs so a retried delivery never runs your handler twice.
Fast-ack
Respond to the provider immediately and hand slow work off to a queue, so a slow handler never causes a provider-side timeout and retry storm.
Typed events
event.type narrows event.data automatically. No casting unknown JSON, no re-deriving types the provider already publishes.
Supported providers
Built-in signature verification for all 9 — see the full provider reference.
Runs everywhere Node runs
One core pipeline, thin adapters per framework. See the framework guides.
3.23 KB min+gzip. Zero runtime dependencies.
Core pipeline plus one provider, tree-shaken. Nothing else ships in node_modules — smaller supply-chain surface, no version conflicts, and it runs unmodified on Node, Bun, Deno, and Cloudflare Workers.