# 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

```bash
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)

```ts
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)

```ts
// 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**

```ts
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**

```ts
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

```ts
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

```ts
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:

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

---

## Testing locally

```bash
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
