JSPM

webhook-sentinelx

0.1.1
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 2
  • Score
    100M100P100Q59251F
  • License MIT

Unified webhook verification and dispatch with normalized events and de-duplication.

Package Exports

  • webhook-sentinelx

Readme

webhook-sentinelx

Unified webhook verification and dispatch with normalized events and de-duplication. TypeScript-first, ESM+CJS, minimal dependencies.

Verify signatures from multiple providers (Stripe, Braintree, GitHub, Slack), normalize event shape, and prevent duplicates with an in-memory or Redis store.


Features

  • Signature verification for Stripe, Braintree, GitHub, Slack. Extensible providers.
  • Normalized event shape: { id, source, type, createdAt, payload, raw, requestId? }.
  • Dedup store options: in-memory by default or Redis as an optional peer dependency.
  • Small dispatcher for ergonomic routing.
  • Framework-friendly: Express, Next.js, or any Node HTTP server using raw body.
  • Typed API and testable design with raw Buffer input.

Installation

npm i webhook-sentinelx
# optional at runtime only if you use these features
npm i braintree redis

braintree and redis are optional peer dependencies. Install them only if you use the Braintree provider or the Redis store in production.

Requirements: Node 18 or newer.


Quick Start (Express)

import express from "express";
import { createVerifier, MemoryStore, createDispatcher } from "webhook-sentinelx";

const app = express();

// Important: Stripe and Slack need the raw body
app.use("/webhooks", express.raw({ type: "*/*" }));

const verify = createVerifier({
  stripe: { endpointSecret: process.env.STRIPE_WEBHOOK_SECRET! },
  github: { secret: process.env.GH_WEBHOOK_SECRET! },
  slack: { signingSecret: process.env.SLACK_SIGNING_SECRET! },
  // Braintree uses its SDK and x-www-form-urlencoded body
  braintree: {
    merchantId: process.env.BT_MERCHANT_ID!,
    publicKey: process.env.BT_PUBLIC_KEY!,
    privateKey: process.env.BT_PRIVATE_KEY!,
    environment: "sandbox", // or "production"
  },
  toleranceSec: 300, // anti replay window
});

const store = new MemoryStore({ ttlSec: 600 });
const on = createDispatcher();

on.on("stripe:payment_intent.succeeded", async (evt) => {
  // your logic
});

app.post("/webhooks/:source", async (req, res) => {
  try {
    const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body);
    const evt = await verify(req.params.source as any, req.headers as any, raw);

    if (await store.seen(evt.id)) return res.sendStatus(202); // duplicate

    await on.dispatch(`${evt.source}:${evt.type}`, evt);
    res.sendStatus(200);
  } catch (e: any) {
    if (e.code === "SIGNATURE_VERIFICATION_FAILED") return res.status(400).send("bad signature");
    if (e.code === "UNSUPPORTED_SOURCE") return res.status(404).send("unknown source");
    if (e.code === "STALE_TIMESTAMP") return res.status(400).send("stale timestamp");
    console.error(e);
    res.sendStatus(500);
  }
});

app.listen(3000, () => console.log("webhook-sentinelx listening on :3000"));

Next.js (Pages router)

// pages/api/webhooks/[source].ts
import type { NextApiRequest, NextApiResponse } from "next";
import { createVerifier, MemoryStore } from "webhook-sentinelx";

export const config = { api: { bodyParser: false } }; // preserve raw body

const verify = createVerifier({
  stripe: { endpointSecret: process.env.STRIPE_WEBHOOK_SECRET! },
});
const store = new MemoryStore({ ttlSec: 600 });

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== "POST") return res.status(405).end();

  const source = req.query.source as any;
  const chunks: Buffer[] = [];
  for await (const c of req) chunks.push(Buffer.from(c));
  const raw = Buffer.concat(chunks);

  try {
    const evt = await verify(source, req.headers as any, raw);
    if (await store.seen(evt.id)) return res.status(202).end();
    // handle evt
    return res.status(200).end();
  } catch (e: any) {
    const code = e.code || "ERR";
    const status =
      code === "UNSUPPORTED_SOURCE" ? 404 : code === "SIGNATURE_VERIFICATION_FAILED" ? 400 : 500;
    return res.status(status).send(code);
  }
}

For App Router or Edge runtime make sure you can access the raw Request body as an ArrayBuffer and pass it as a Buffer to verify().


API

createVerifier(config) returns (source, headers, rawBody) => NormalizedEvent

Config

export type VerifierConfig = {
  toleranceSec?: number; // anti replay window, default 300
  stripe?: { endpointSecret: string };
  braintree?: {
    merchantId: string;
    publicKey: string;
    privateKey: string;
    environment?: "sandbox" | "production";
  };
  github?: { secret: string };
  slack?: { signingSecret: string };
};

NormalizedEvent

export type NormalizedEvent = {
  id: string;
  source: "stripe" | "braintree" | "github" | "slack";
  type: string;
  createdAt: Date;
  payload: unknown; // original parsed JSON or SDK object for Braintree
  raw: Buffer; // raw request body
  requestId?: string;
};

Errors thrown as WebhookSentinelxdError with code:

  • UNSUPPORTED_SOURCE
  • SIGNATURE_VERIFICATION_FAILED
  • STALE_TIMESTAMP

Providers

  • Stripe reads Stripe-Signature header and verifies HMAC SHA256 over t.rawBody. Requires raw body.
  • GitHub reads x-hub-signature-256 with sha256= prefix. HMAC SHA256 over raw body.
  • Slack reads x-slack-request-timestamp and x-slack-signature. HMAC SHA256 over v0:{ts}:{raw}. Requires raw body.
  • Braintree consumes x-www-form-urlencoded with bt_signature and bt_payload through the official SDK.

Raw body note: For HMAC verification providers do not JSON parse before verification. Pass the original bytes.

Stores for de-duplication

import { MemoryStore, RedisStore } from "webhook-sentinelx";

const memory = new MemoryStore({ ttlSec: 600 });
await memory.seen("event-id"); // false on first call, true afterwards within TTL

const redis = new RedisStore({ url: process.env.REDIS_URL!, keyPrefix: "webhook-sentinelx" });
await redis.seen("event-id");

Dispatcher helper

import { createDispatcher } from "webhook-sentinelx";

const bus = createDispatcher();
bus.on("stripe:payment_intent.succeeded", (evt) => {
  /*...*/
});
bus.on("github:push", (evt) => {
  /*...*/
});
await bus.dispatch("stripe:payment_intent.succeeded", evt);

TypeScript and Module Formats

Published as dual ESM (.mjs) and CJS (.cjs) with typings.

Import examples:

// ESM
import { createVerifier } from "webhook-sentinelx";
// CJS
const { createVerifier } = require("webhook-sentinelx");

Testing locally

npm i -D typescript tsup vitest @types/node
npm run test
npm run build

Security notes

  • Use toleranceSec to reject replayed events with stale timestamps.
  • Always use HTTPS for webhook endpoints.
  • Keep provider secrets in a secure manager or environment.

Roadmap

  • More providers: Shopify, PayPal non Braintree, Plaid, Twilio, Notion.
  • Pluggable retry or queue adapters such as SQS or Kafka.
  • Delivery receipts and replay helpers.

License

MIT 2025