The inbound webhook pipeline for TypeScript
Verify, deduplicate, and process inbound webhooks from Stripe, GitHub, Shopify, and more — with atomic idempotency, fast acknowledgement, and 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
Signature verification for 9 providers using Web Crypto — works on Node, Bun, Deno, and edge runtimes.
Deduplicate
Atomic idempotency with Redis or Prisma — exactly one handler invocation per event ID, even under concurrent retries.
Acknowledge fast
Verify, claim, enqueue, return 200 — in under a second. Process the real work on a BullMQ worker.
Handle typed events
Your handler receives a fully verified, deduplicated, parsed event — typed per provider, never unknown JSON.
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.