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.

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

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.

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