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 overt.rawBody
. Requires raw body. - GitHub reads
x-hub-signature-256
withsha256=
prefix. HMAC SHA256 over raw body. - Slack reads
x-slack-request-timestamp
andx-slack-signature
. HMAC SHA256 overv0:{ts}:{raw}
. Requires raw body. - Braintree consumes
x-www-form-urlencoded
withbt_signature
andbt_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