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.

3.23 KB min+gzipZero runtime dependencies9 providers

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.

Hand-rolled — Express~40 lines
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);
    }
  },
);
With @hooksentinel/core~8 lines of logic
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.

Stripe
GitHub
Shopify
Standard Webhooks
Slack
Discord
Twilio
Paddle
Generic

Runs everywhere Node runs

One core pipeline, thin adapters per framework. See the framework guides.

NodeExpressFastifyNestJSNext.jsHonoLambdaBunDenoCloudflare Workers

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.

See the breakdown

Verify your first webhook in under a minute

Get started