# Define events (/docs/define-events) Events are a plain object. Each key is an event name, each value is the schema of that event's `data`. ```ts title="src/events.ts" import { z } from "zod"; export const events = { "invoice.paid": z.object({ invoiceId: z.string(), amountCents: z.number() }), "member.invited": z.object({ email: z.string().email(), role: z.enum(["admin", "viewer"]), }), }; ``` Pass it to `webhooks({ events })`. From then on, `send()` only accepts known event names, and the name narrows `data`: ```ts await wh.send({ event: "member.invited", subject: "acct_42", data: { email: "ana@northwind.dev", role: "admin" }, }); ``` ## Any Standard Schema [#any-standard-schema] The SDK only talks to the [Standard Schema](https://standardschema.dev) interface, so zod (3.24 or later), valibot and arktype all work, and you can mix them in one map. ```ts import * as v from "valibot"; import { type } from "arktype"; export const events = { "invoice.paid": v.object({ invoiceId: v.string(), amountCents: v.number() }), "invoice.voided": type({ invoiceId: "string" }), }; ``` ## What gets validated [#what-gets-validated] `send()` validates `data` against the event's schema before anything else happens. Nothing is sent, and nothing is written to the outbox, when it fails: | Problem | `result.error` | | ------------------------------- | ---------------------------------------------------- | | An event name not in the map | `unknown event type "…"` | | Data that fails the schema | `invalid payload for "…": ` and the issue messages | | The validator itself throws | `schema validation threw for "…": …` | | Output that can't be serialized | `payload for "…" is not JSON-serializable: …` | The validated **output** is what gets sent. Defaults and transforms in your schema run before the payload is signed. ## Types on the wire [#types-on-the-wire] The body is JSON, so some types change on the way. A `z.date()` field is sent as an ISO string, and a `bigint` can't be serialized at all (`send()` returns an error). `EventUnion` describes the payload as consumers receive it, after that round trip: ```ts import type { EventUnion } from "@typedhook/sdk"; export type Events = EventUnion; // { event: "invoice.paid"; subject: string | null; data: { invoiceId: string; amountCents: number } } | … ``` See [Typed payloads](/docs/typed-payloads) for how consumers use it. # Direct delivery (/docs/direct-delivery) When you don't pass `delivery`, `webhooks()` uses `direct()`. `send()` signs the event and posts it to every subscriber, in parallel, then resolves once each one has succeeded or run out of attempts. ```ts import { direct, webhooks } from "@typedhook/sdk"; const wh = webhooks({ events, subscribers, signing: { secret: process.env.WEBHOOK_SECRET! }, delivery: direct({ maxAttempts: 5, timeoutMs: 10_000 }), }); ``` ## Reading the result [#reading-the-result] `result.ok` only says the event was valid and sent out. Check each delivery to see whether it landed: ```ts const result = await wh.send({ event: "invoice.paid", subject: "acct_42", data }); if (!result.ok) return console.error(result.error); for (const delivery of result.value.deliveries) { if (!delivery.delivered) console.warn(delivery.url, delivery.attempts, delivery.error); } ``` Each delivery has `url`, `delivered` and `attempts`. A delivered one has the response `status`, a failed one has the last `error`, like `endpoint responded 503` or `request failed: …`. ## Retries [#retries] Any 2xx counts as delivered. Anything else is a failed attempt: a non-2xx status, a network error, or no answer within `timeoutMs`. Between attempts, `send()` waits `backoffMs(failedAttempts)`, by default `min(30s, 500ms · 2^(n−1))`: | After failed attempt | Wait | | -------------------- | ----- | | `1` | 0.5s | | `2` | 1s | | `3` | 2s | | `4` | 4s | With the default 5 attempts, a dead endpoint keeps `send()` waiting at least 7.5 seconds, plus the time each request takes. Every attempt is signed again with a fresh timestamp, so a retry never falls outside the receiver's tolerance window. **Retries live in memory.** If the process crashes or restarts while `send()` is waiting, the remaining attempts are lost. Use the [outbox](/docs/outbox) for events that must arrive. ## Redirects [#redirects] Redirects are not followed. A 3xx is a failed attempt, so a signed payload is never forwarded to another host. ## Options [#options] | Option | Default | What it does | | ------------- | -------------------------------- | ----------------------------------------------- | | `maxAttempts` | `5` | Attempts per subscriber, including the first | | `backoffMs` | `min(30_000, 500 * 2 ** (n - 1))` | Wait after the `n`th failed attempt, in ms | | `timeoutMs` | `10000` | Per-request timeout | | `fetchImpl` | `fetch` | Swap the HTTP client, for tests or proxies | `maxAttempts` must be a positive integer. Anything else throws a `RangeError` when you call `direct()`. See the [`direct()` reference](/docs/reference/direct). # Failure reasons (/docs/failure-reasons) A failed verification resolves to `{ ok: false, reason }`. It never throws, so a bad request can't crash your handler. Checks run in this order and stop at the first failure: The body is only read once the headers pass, so a request without valid headers is rejected without buffering its body. ## Reasons [#reasons] | Reason | When | Usual cause | | ---------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------- | | `missing_headers` | No `webhook-id`, `webhook-timestamp` or `webhook-signature` (or `svix-*`) | Not a webhook, or a proxy stripped the headers | | `invalid_id` | The id contains a dot, which the spec forbids | A malformed or forged request | | `invalid_timestamp` | The timestamp is not an integer of seconds | A date string or a decimal instead of Unix seconds | | `timestamp_out_of_tolerance` | More than `toleranceSec` (300 by default) from now, either way | A replayed request, a clock that's off, or milliseconds | | `unreadable_body` | The `Request` body was already read, or the stream failed | Another middleware read it first. Pass `req.clone()` | | `invalid_secret` | The secret could not be imported as an HMAC key | Rare: `createVerifier()` already rejects bad secrets | | `invalid_signature` | None of the `v1,` signatures match | The wrong secret, or a body that was parsed and re-serialized | | `invalid_json` | The signature is valid but the body is not JSON | The sender signed something that isn't JSON | `unreadable_body` only comes from `verify()`. `verifyPayload()` gets the body from you. ## Signatures [#signatures] The `webhook-signature` header can hold several space-separated signatures, like `v1,abc… v1,def…`, which senders use while rotating keys. The verifier tries up to 8 `v1` entries and accepts the request if any of them matches. Entries with another version prefix are skipped. ## Tolerance [#tolerance] The timestamp check is what stops replays: a captured request can't be sent again once it's older than the tolerance. Widen it only if your clocks drift: ```ts const verifier = createVerifier(process.env.WEBHOOK_SECRET!, { toleranceSec: 600 }); ``` `toleranceSec` must be a finite number, 0 or more. Anything else throws when you create the verifier. ## Responding [#responding] Answer failures with a 4xx. A sender like `@typedhook/sdk` treats any non-2xx as a failed attempt and retries it later, so fixing a wrong secret on your side lets the next attempt through. Return `result.reason` in the body when the sender is yours, and keep it out of public responses if you'd rather not tell a forger which check failed. # Frameworks (/docs/frameworks) There are two ways in, depending on what your framework hands you: - `verifier.verify(request)` takes a Fetch API `Request`. Use it with Next.js, Hono, Remix, Cloudflare Workers, Bun and Deno. - `verifier.verifyPayload(body, headers)` takes the raw body as a string or bytes, plus the headers. Use it with Express, Fastify and anything else built on Node's `http` module. Both return the same result and never throw. ## Next.js [#nextjs] A route handler in the App Router receives a `Request`: ```ts title="app/api/webhooks/route.ts" import { createVerifier } from "@typedhook/verify"; const verifier = createVerifier(process.env.WEBHOOK_SECRET!); export async function POST(req: Request) { const result = await verifier.verify(req); if (!result.ok) return new Response(result.reason, { status: 400 }); await handle(result.payload); return new Response(null, { status: 204 }); } ``` `verify()` reads the body. If you need it again afterwards, pass `req.clone()`. ## Hono [#hono] `c.req.raw` is the underlying `Request`: ```ts title="src/index.ts" import { Hono } from "hono"; import { createVerifier } from "@typedhook/verify"; const app = new Hono(); const verifier = createVerifier(process.env.WEBHOOK_SECRET!); app.post("/webhooks", async (c) => { const result = await verifier.verify(c.req.raw); if (!result.ok) return c.text(result.reason, 400); return c.body(null, 204); }); ``` ## Cloudflare Workers [#cloudflare-workers] Secrets arrive on `env`, so create the verifier inside `fetch`: ```ts title="src/index.ts" import { createVerifier } from "@typedhook/verify"; export default { async fetch(req: Request, env: { WEBHOOK_SECRET: string }) { const verifier = createVerifier(env.WEBHOOK_SECRET); const result = await verifier.verify(req); if (!result.ok) return new Response(result.reason, { status: 400 }); return new Response(null, { status: 204 }); }, }; ``` ## Express [#express] The signature covers the exact bytes that were sent. `express.json()` parses them, and re-serializing the result rarely gives the same bytes back. Take the raw body for this route instead: ```ts title="src/server.ts" import express from "express"; import { createVerifier } from "@typedhook/verify"; const app = express(); const verifier = createVerifier(process.env.WEBHOOK_SECRET!); app.post("/webhooks", express.raw({ type: "application/json" }), async (req, res) => { const result = await verifier.verifyPayload(req.body, req.headers); if (!result.ok) return res.status(400).send(result.reason); await handle(result.payload); res.status(204).end(); }); ``` `express.raw()` gives you a `Buffer`, which `verifyPayload()` accepts as bytes. Register the route before any global `express.json()`, or that parser consumes the body first. ## Fastify [#fastify] Fastify parses JSON by default. Keep the body as a `Buffer` with a content type parser: ```ts title="src/server.ts" import Fastify from "fastify"; import { createVerifier } from "@typedhook/verify"; const app = Fastify(); const verifier = createVerifier(process.env.WEBHOOK_SECRET!); app.addContentTypeParser("application/json", { parseAs: "buffer" }, (_req, body, done) => { done(null, body); }); app.post<{ Body: Buffer }>("/webhooks", async (request, reply) => { const result = await verifier.verifyPayload(request.body, request.headers); if (!result.ok) return reply.code(400).send(result.reason); return reply.code(204).send(); }); ``` The parser applies to every route in that Fastify instance. Register the webhook route in its own plugin if other routes need parsed JSON. ## Headers from anywhere [#headers-from-anywhere] `verifyPayload()` takes a `Headers` object or a plain object. Plain objects can use any casing, and array values use their first entry, so Node's `IncomingHttpHeaders` work as they are. # Handling duplicates (/docs/handling-duplicates) Delivery is at least once. The same webhook arrives again when: - your handler worked, but its 2xx was lost or came after the sender's timeout, - an outbox worker crashed after posting, before it marked the row delivered, - the sender retried a failed attempt while an earlier one was still being processed. ## Store the id [#store-the-id] `result.id` comes from the `webhook-id` header. It is the same on every retry of one event, so record it and skip ids you have already processed. A unique constraint makes the check atomic: ```ts title="app/api/webhooks/route.ts" const result = await verifier.verify(req); if (!result.ok) return new Response(result.reason, { status: 400 }); const fresh = await db.processedWebhook.createMany({ data: [{ id: result.id }], skipDuplicates: true, }); if (fresh.count === 0) return new Response(null, { status: 204 }); // seen it await handle(result.payload); return new Response(null, { status: 204 }); ``` In SQL, it's `INSERT … ON CONFLICT (id) DO NOTHING` and a check of the affected row count. Answer duplicates with a 2xx. Anything else counts as a failed attempt, and the sender keeps retrying. ## Record it with the work [#record-it-with-the-work] If `handle()` fails after the id is stored, the retry is skipped and the event is lost. Store the id in the same transaction as the work it triggers: ```ts const handled = await db.$transaction(async (tx) => { const fresh = await tx.processedWebhook.createMany({ data: [{ id: result.id }], skipDuplicates: true }); if (fresh.count === 0) return false; await applyPayment(tx, result.payload); return true; }); ``` If the transaction rolls back, the id goes with it, and the sender's next attempt is processed. ## Out of order [#out-of-order] Retries mean an older event can land after a newer one. Don't use `result.timestamp` to order them: it's when that attempt was signed, and every retry gets a fresh one. Put the time the change happened in the event's `data`, and ignore events older than what you've stored. # Introduction (/docs) Define events as schemas. `send()` validates the data and delivers it, signed, to each subscriber. ```ts title="webhooks.ts" import { webhooks } from "@typedhook/sdk"; import { z } from "zod"; // or valibot, arktype export const wh = webhooks({ events: { "invoice.paid": z.object({ invoiceId: z.string(), amountCents: z.number() }), }, subscribers: async ({ event, subject }) => db.findSubscribers(event, subject), signing: { secret: process.env.WEBHOOK_SECRET! }, }); const result = await wh.send({ event: "invoice.paid", subject: "acct_42", data: { invoiceId: "inv_1", amountCents: 4200 }, }); if (!result.ok) console.error(result.error); ``` ## Features [#features] Use zod, valibot or arktype. The event name types `data`. Events are saved in your transaction and retried by a worker. Runs on Node 20+, Bun, Deno, Workers and edge. Export `EventUnion`. Receivers import it as a type. ## Read next [#read-next] # Installation (/docs/installation) Each package is installed on its own. A typical setup puts the sender in the app that emits events and the verifier in the apps that receive them. ## The sender [#the-sender] `@typedhook/sdk` sends webhooks. Install it with the schema library you already use. Any library that implements [Standard Schema](https://standardschema.dev) works: zod 3.24 or later, valibot and arktype. ```package-install npm install @typedhook/sdk zod ``` It needs Node 20 or later, or Bun. It uses the global `fetch` and WebCrypto, and has no dependencies. ## The verifier [#the-verifier] `@typedhook/verify` verifies webhooks you receive. It has no dependencies and only uses WebCrypto, so it runs on Node 20+, Bun, Deno, Cloudflare Workers and edge runtimes. ```package-install npm install @typedhook/verify ``` You don't need the sender to use it. It verifies any webhook signed in the Standard Webhooks format, including ones sent through Svix. ## The Prisma outbox [#the-prisma-outbox] `@typedhook/prisma` stores the [outbox](/docs/outbox) in Postgres with Prisma. Install it next to the sender, which is a peer dependency. ```package-install npm install @typedhook/sdk @typedhook/prisma ``` Then add the outbox model to your schema. See [Prisma adapter](/docs/prisma-adapter). ## Module formats [#module-formats] All three packages ship ESM and CommonJS builds with their type declarations. Under Bun, the packages resolve to their TypeScript sources. # How the outbox works (/docs/outbox) With `direct()` delivery, retries live in memory and a crash loses them. The outbox moves that state into a table you already back up: an event is only written if your transaction commits, and it stays pending until a subscriber answers with a 2xx or it runs out of attempts. ## Enqueue inside your transaction [#enqueue-inside-your-transaction] Pass an adapter to `outbox()`. Then `wh.with(tx).send()` writes the row with the same transaction client as your own writes. ```ts title="src/webhooks.ts" import { webhooks, outbox } from "@typedhook/sdk"; import { prismaOutboxAdapter } from "@typedhook/prisma"; import type { Prisma } from "@prisma/client"; export const wh = webhooks({ events, subscribers, signing: { secret: process.env.WEBHOOK_SECRET! }, delivery: outbox(prismaOutboxAdapter(prisma)), }); await prisma.$transaction(async (tx) => { const invoice = await tx.invoice.create({ data }); await wh.with(tx).send({ event: "invoice.paid", subject: invoice.accountId, data: { invoiceId: invoice.id, amountCents: invoice.total }, }); }); ``` **This is the one place `send()` throws.** If the insert fails, the error propagates so your transaction rolls back. Validation errors still return `{ ok: false }`. ## Run a worker [#run-a-worker] The worker claims due rows, signs each one with a fresh timestamp and posts it. Keep it running in a long-lived process, or call `tick()` from a cron job. ```ts tab="Long-running" const worker = wh.worker({ pollIntervalMs: 1000, batch: 100, hooks: { onDead: (row, error) => alerts.notify(`webhook ${row.eventId} died: ${error}`), }, }); worker.start(); process.on("SIGTERM", () => worker.stop()); // waits for in-flight deliveries ``` ```ts tab="Cron" // app/api/cron/webhooks/route.ts import { wh } from "@/lib/webhooks"; export async function GET() { const report = await wh.worker().tick(); // one claim + deliver pass return Response.json(report); // { claimed, delivered, retried, dead } } ``` | Option | Default | What it does | | ---------------- | ------- | ----------------------------------------------------------------------------------- | | `pollIntervalMs` | 1000 | Wait between ticks when running with `start()` | | `batch` | 100 | Rows claimed per tick | | `timeoutMs` | 10000 | Per-request timeout. A timeout counts as a failed attempt | | `hooks` | — | `onDelivered`, `onRetry`, `onDead`, `onError`. A throwing hook never stops the loop | | `fetchImpl` | fetch | Swap the HTTP client, for tests or proxies | ## Delivery guarantees [#delivery-guarantees] 1. **At least once.** A worker that crashes mid-delivery leaves its rows claimed. They become due again and are sent again, so receivers must dedupe on `webhook-id`. 2. **Safe with many workers** if `claim` marks the rows it returns. The Prisma adapter uses `FOR UPDATE SKIP LOCKED` and pushes `nextAttemptAt` forward. 3. **Checked by the compiler.** `wh.with()` and `wh.worker()` only exist when `delivery` is `outbox(...)`. Calling them otherwise is a type error. 4. **No redirects.** A 3xx counts as a failed attempt, so a signed payload is never replayed to another host. ## Adapters [#adapters] Use the Prisma adapter on Postgres, `memoryAdapter()` in tests, or write your own in five functions. # Prisma adapter (/docs/prisma-adapter) `@typedhook/prisma` is an outbox adapter for Prisma on Postgres. Rows are claimed with `FOR UPDATE SKIP LOCKED`, so concurrent workers never deliver the same row twice at the same time. **Postgres only.** The claim query is raw SQL. ## Install [#install] ```package-install npm install @typedhook/sdk @typedhook/prisma ``` ## Schema [#schema] Paste the model into `schema.prisma`, then create a migration. Keep Prisma's default column names (no `@map`): the raw claim query addresses them directly. ```prisma title="prisma/schema.prisma" model WebhookOutbox { id String @id eventId String eventType String subject String? body String url String secret String? attempts Int nextAttemptAt DateTime status String lastError String? createdAt DateTime @@index([status, nextAttemptAt]) } ``` Renamed the model or mapped the table? Pass `{ model, table }` to match. ## Usage [#usage] ```ts title="src/webhooks.ts" import { webhooks, outbox } from "@typedhook/sdk"; import { prismaOutboxAdapter } from "@typedhook/prisma"; import { PrismaClient, type Prisma } from "@prisma/client"; import { z } from "zod"; const prisma = new PrismaClient(); const wh = webhooks({ events: { "user.created": z.object({ userId: z.string() }) }, subscribers: [{ url: "https://consumer.example/hook" }], signing: { secret: process.env.WEBHOOK_SECRET! }, delivery: outbox(prismaOutboxAdapter(prisma)), }); await prisma.$transaction(async (tx) => { const user = await tx.user.create({ data: { email: "a@b.c" } }); await wh.with(tx).send({ event: "user.created", subject: user.id, data: { userId: user.id }, }); }); wh.worker().start(); ``` Pass `Prisma.TransactionClient` as the type argument. That's what makes `wh.with(tx)` accept the `tx` from `$transaction` and reject anything else. `send()` without `with()` inserts through the client you passed to the adapter, outside any transaction. ## Options [#options] | Option | Default | What it does | | ---------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `model` | `"webhookOutbox"` | Property on the client holding the model delegate | | `table` | `"WebhookOutbox"` | Table name used by the raw claim query | | `claimTimeoutMs` | `60000` | How long claimed rows are hidden from other workers. If a worker crashes mid-delivery, its rows become due again after this. Keep it above the worker's `timeoutMs`. | `table` must be a plain SQL identifier (letters, digits and underscores). Anything else throws when you create the adapter, and so does a `model` the client doesn't have. ## How it claims [#how-it-claims] ```sql UPDATE "WebhookOutbox" SET "nextAttemptAt" = $1 WHERE "id" IN ( SELECT "id" FROM "WebhookOutbox" WHERE "status" = 'pending' AND "nextAttemptAt" <= $2 ORDER BY "nextAttemptAt" LIMIT $3 FOR UPDATE SKIP LOCKED ) RETURNING * ``` - Claimed rows stay `status: "pending"`. Their `nextAttemptAt` is pushed `claimTimeoutMs` into the future, so other workers skip them. A crashed worker's rows are delivered again after the timeout: delivery is at least once. - `complete`, `retry` and `dead` are `updateMany` calls. Updating a row that was deleted is a no-op, not an error. - Rows are never deleted. Delivered and dead rows stay in the table with `status` set to `delivered` or `dead`, so clean them up on your own schedule. # Quickstart (/docs/quickstart) ### Install the sender [#install-the-sender] Bring any Standard Schema library. These docs use zod. ```package-install npm install @typedhook/sdk zod ``` ### Define your events [#define-your-events] Each key is an event name, each value is the schema of its `data`. Export `Events` so the apps that receive them can import the types. ```ts title="src/events.ts" import { type EventUnion } from "@typedhook/sdk"; import { z } from "zod"; export const events = { "invoice.paid": z.object({ invoiceId: z.string(), amountCents: z.number() }), "member.invited": z.object({ email: z.string().email(), role: z.enum(["admin", "viewer"]), }), }; export type Events = EventUnion; ``` ### Create the sender [#create-the-sender] `subscribers` can be a static list or a function that looks them up. A subscriber's own `secret` wins over `signing.secret`. ```ts title="src/webhooks.ts" import { webhooks } from "@typedhook/sdk"; import { events } from "./events"; export const wh = webhooks({ events, subscribers: [{ url: "https://hooks.northwind.dev/billing" }], signing: { secret: process.env.WEBHOOK_SECRET! }, // whsec_… }); ``` ### Send an event [#send-an-event] `send()` doesn't throw. Invalid data, an unknown event or a failing resolver come back as `{ ok: false, error }`. ```ts title="src/billing.ts" const result = await wh.send({ event: "invoice.paid", subject: "acct_42", // who the event is about data: { invoiceId: "inv_1", amountCents: 4200 }, }); if (!result.ok) throw new Error(result.error); result.value.deliveries; // [{ url, delivered, attempts, status }] ``` ### Verify it on the other side [#verify-it-on-the-other-side] In the receiving app, install `@typedhook/verify` and check every request before you trust it. ```ts title="app/api/webhooks/route.ts" import { createVerifier } from "@typedhook/verify"; import type { Events } from "@acme/sdk"; const verifier = createVerifier(process.env.WEBHOOK_SECRET!); export async function POST(req: Request) { const result = await verifier.verify(req); if (!result.ok) return new Response(result.reason, { status: 400 }); if (result.payload.event === "invoice.paid") { result.payload.data.amountCents; // number } return new Response(null, { status: 204 }); } ``` **Retries live in memory by default.** `direct()` makes up to 5 attempts with backoff, but a crash loses them. If an event must not be lost, use the [outbox](/docs/outbox). **Expect duplicates.** Webhooks can arrive more than once and out of order. `result.id` is the same on every retry, so store it and skip ids you have seen. # createVerifier() (/docs/reference/create-verifier) ```ts import { createVerifier } from "@typedhook/verify"; function createVerifier(secret: string, opts?: { toleranceSec?: number }): Verifier; interface Verifier { verify(request: Request): Promise>; verifyPayload( payload: string | Uint8Array, headers: Headers | Record, ): Promise>; } ``` ## Options [#options] | Option | Default | What it does | | -------------- | ------- | --------------------------------------------------------------- | | `toleranceSec` | `300` | How far the `webhook-timestamp` may be from now, in either direction | ## Throws [#throws] `createVerifier()` throws a `RangeError` at startup when: - `secret` is empty, or starts with `whsec_` without valid, non-empty base64 after it. - `toleranceSec` isn't a finite number of 0 or more. `verify()` and `verifyPayload()` never throw. ## Result [#result] ```ts type VerifyResult = | { ok: true; id: string; timestamp: Date; payload: T } | { ok: false; reason: VerifyFailure }; type VerifyFailure = | "missing_headers" | "invalid_id" | "invalid_timestamp" | "timestamp_out_of_tolerance" | "invalid_signature" | "invalid_secret" | "unreadable_body" | "invalid_json"; ``` | Field | What it is | | ----------- | ------------------------------------------------------------------ | | `id` | The `webhook-id` header. Same on every retry, use it to dedupe | | `timestamp` | The `webhook-timestamp` header as a `Date`: when this attempt was signed | | `payload` | The parsed JSON body, typed as `T` but not validated | See [Failure reasons](/docs/failure-reasons) for when each reason is returned. ## verify() and verifyPayload() [#verify-and-verifypayload] | Method | Input | Notes | | ----------------- | ----------------------------------------------- | ---------------------------------------------------- | | `verify()` | A Fetch API `Request` | Reads the body. Pass `req.clone()` to read it again | | `verifyPayload()` | The raw body as a `string` or `Uint8Array`, and the headers | Plain header objects can use any casing | Both accept `webhook-*` headers and fall back to `svix-*`. Secrets that start with `whsec_` are base64-decoded. Any other secret is used as plain text. # direct() (/docs/reference/direct) ```ts import { direct } from "@typedhook/sdk"; function direct(opts?: { maxAttempts?: number; backoffMs?: (attempts: number) => number; timeoutMs?: number; fetchImpl?: typeof fetch; }): DirectDelivery; ``` ```ts const wh = webhooks({ events, subscribers, delivery: direct({ maxAttempts: 3 }) }); ``` ## Options [#options] | Option | Default | What it does | | ------------- | ------------------------------------- | ---------------------------------------------------------- | | `maxAttempts` | `5` | Attempts per subscriber, including the first | | `backoffMs` | `(n) => Math.min(30_000, 500 * 2 ** (n - 1))` | Wait in ms after `n` failed attempts, starting at 1 | | `timeoutMs` | `10000` | Per-request timeout | | `fetchImpl` | `fetch` | The HTTP client | ## Behavior [#behavior] - `send()` posts to all subscribers in parallel and resolves when each one has succeeded or used up its attempts. - A 2xx is a success. Any other status, a network error or a timeout is a failed attempt. Redirects are not followed. - Every attempt is signed with a fresh timestamp. - Retries live in the process. A crash or restart loses them. Use [`outbox()`](/docs/reference/outbox) when that matters. ## Throws [#throws] `RangeError` when `maxAttempts` isn't a positive integer, so a bad value fails at startup. # outbox() (/docs/reference/outbox) ```ts import { outbox } from "@typedhook/sdk"; function outbox( adapter: OutboxAdapter, opts?: { maxAttempts?: number; backoffMs?: (attempts: number) => number; }, ): OutboxDelivery; ``` ```ts const wh = webhooks({ events, subscribers, signing: { secret: process.env.WEBHOOK_SECRET! }, delivery: outbox(prismaOutboxAdapter(prisma), { maxAttempts: 20 }), }); ``` `Tx` comes from the adapter and becomes the type `wh.with(tx)` accepts. ## Options [#options] | Option | Default | What it does | | ------------- | ----------------------------------------------- | -------------------------------------------------- | | `maxAttempts` | `20` | Attempts per row before it's marked `dead` | | `backoffMs` | `(n) => Math.min(3_600_000, 30_000 * 2 ** (n - 1))` | Wait in ms after `n` failed attempts, starting at 1 | The default backoff doubles from 30 seconds and stops growing at an hour: | After failed attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 and later | | -------------------- | --- | -- | -- | -- | -- | --- | --- | ----------- | | Wait | 30s | 1m | 2m | 4m | 8m | 16m | 32m | 1h | With 20 attempts, a row that never succeeds is retried for about 13 hours before it's marked `dead`. ## Behavior [#behavior] - `send()` validates the event, resolves subscribers and inserts one row per subscriber. It makes no HTTP requests. - When there are no subscribers, nothing is inserted. - If the insert fails, `send()` rejects instead of returning an error, so your transaction rolls back. - A [worker](/docs/reference/worker) delivers the rows. ## Throws [#throws] `RangeError` when `maxAttempts` isn't a positive integer. ## OutboxAdapter [#outboxadapter] ```ts interface OutboxAdapter { insert: (rows: OutboxRow[], tx?: Tx) => Promise; claim: (limit: number, now: Date) => Promise; complete: (rowId: string) => Promise; retry: (rowId: string, attempts: number, nextAttemptAt: Date, lastError: string) => Promise; dead: (rowId: string, attempts: number, lastError: string) => Promise; } ``` See [Write an adapter](/docs/write-an-adapter) for the contract of each function and the `OutboxRow` fields. # webhooks() (/docs/reference/webhooks) ```ts import { webhooks } from "@typedhook/sdk"; function webhooks( config: WebhooksConfig & { subject?: S; delivery?: DirectDelivery }, ): DirectWebhooks; function webhooks( config: WebhooksConfig & { subject?: S; delivery: OutboxDelivery }, ): OutboxWebhooks; ``` The return type depends on `delivery`. Pass `outbox(...)` and you get `with()` and `worker()`. Leave it out, or pass `direct(...)`, and you only get `send()`. ## Config [#config] | Option | Type | Default | What it does | | ------------- | ---------------------------------------- | ----------- | ---------------------------------------------------------------- | | `events` | `Record` | required | Event names and the schema of each one's `data` | | `subscribers` | `Subscriber[] \| SubscriberResolver` | required | Who receives events. See [Subscribers](/docs/subscribers) | | `signing` | `{ secret: string }` | — | Secret for subscribers that don't have their own | | `subject` | `"optional"` | — | Makes `subject` optional in `send()`. It's required without it | | `delivery` | `DirectDelivery \| OutboxDelivery` | `direct()` | [`direct()`](/docs/reference/direct) or [`outbox()`](/docs/reference/outbox) | ## send() [#send] ```ts send(input: { event: K; subject: string; // optional with subject: "optional" data: StandardSchemaV1.InferInput; }): Promise>; // Result with outbox() ``` ```ts type Result = { ok: true; value: T } | { ok: false; error: string }; interface DirectSendReport { eventId: string; deliveries: { url: string; delivered: boolean; attempts: number; status?: number; error?: string }[]; } interface EnqueueReport { eventId: string; enqueued: number; // one row per subscriber } ``` `send()` returns `{ ok: false, error }` instead of throwing. Possible errors: | `error` | Cause | | --------------------------------------------- | ---------------------------------------------------- | | `event "…" requires a subject` | No `subject`, and the config doesn't make it optional | | `subject must be a non-empty string` | `subject: ""` | | `unknown event type "…"` | The event isn't in `events` | | `invalid payload for "…": …` | `data` failed the schema | | `schema validation threw for "…": …` | The validator threw | | `payload for "…" is not JSON-serializable: …` | The validated output can't be stringified | | `subscriber resolver failed: …` | The resolver threw or rejected | | `no signing secret for subscriber …` | Neither the subscriber nor `signing` has a secret | | `invalid signing secret for subscriber …` | A `whsec_` secret without valid base64 | With `outbox()`, an error from the adapter's `insert` is not caught: `send()` rejects, so the surrounding transaction rolls back. ## with(tx) and worker() [#withtx-and-worker] Only on `outbox()` senders. ```ts with(tx: Tx): { send(input): Promise> }; worker(opts?: WorkerOpts): OutboxWorker; ``` `with(tx)` passes `tx` to the adapter's `insert`, so the row commits with your transaction. See [`worker()`](/docs/reference/worker). ## Other exports [#other-exports] | Export | What it is | | ------------------------------- | ------------------------------------------------------------------ | | `EventUnion` | The payloads as receivers get them. See [Typed payloads](/docs/typed-payloads) | | `ResolverEvent` | What a subscriber resolver receives | | `Jsonify` | `T` after a JSON round trip | | `memoryAdapter()` | In-memory outbox adapter. See [Testing](/docs/testing) | | `signPayload()`, `isValidSecret()` | The signing helpers. See [Signing secrets](/docs/signing-secrets) | | `ok()`, `err()` | Build a `Result` | # worker() (/docs/reference/worker) ```ts wh.worker(opts?: WorkerOpts): OutboxWorker; interface OutboxWorker { start(): void; stop(): Promise; tick(): Promise; } interface TickReport { claimed: number; delivered: number; retried: number; dead: number; } ``` Only exists when `delivery` is [`outbox()`](/docs/reference/outbox). Calling it on a direct sender is a type error. ## Options [#options] | Option | Default | What it does | | ---------------- | ------- | --------------------------------------------------------- | | `pollIntervalMs` | `1000` | Wait after each tick before the next, with `start()` | | `batch` | `100` | Rows claimed per tick | | `timeoutMs` | `10000` | Per-request timeout. A timeout counts as a failed attempt | | `fetchImpl` | `fetch` | The HTTP client | | `hooks` | `{}` | See below | ## Hooks [#hooks] ```ts interface WorkerHooks { onDelivered?(row: OutboxRow): void; onRetry?(row: OutboxRow, error: string): void; onDead?(row: OutboxRow, error: string): void; onError?(error: unknown): void; } ``` `onError` receives adapter failures, like a `claim` that rejects. Errors thrown by any hook are swallowed, so a broken hook can't stop the loop or `stop()`. ## Methods [#methods] | Method | What it does | | --------- | -------------------------------------------------------------------------------------------------- | | `start()` | Runs a tick now, then one `pollIntervalMs` after each tick finishes. Does nothing if already running | | `stop()` | Stops scheduling ticks and resolves once in-flight deliveries finish. Never rejects | | `tick()` | One pass: claim up to `batch` rows and deliver them. Use it from cron instead of `start()` | ## Per row [#per-row] The worker signs the stored body with the row's secret, or `signing.secret` when the row has none, and a fresh timestamp. Then: - 2xx: `complete(row)`, then `onDelivered`. - Failure with attempts left: `retry(row, attempts, now + backoffMs(attempts), error)`, then `onRetry`. - Failure on attempt `maxAttempts`: `dead(row, attempts, error)`, then `onDead`. One row's adapter failure doesn't abandon the others in the same tick. # Coming soon (/docs/roadmap) ## Event registry [#event-registry] A [hosted event registry](https://github.com/ThallesP/webhooks/issues/11). Publish your events, and consumers pull them as types with one command, instead of installing a package that exports [`EventUnion`](/docs/typed-payloads). ## Consumer SDK CLI [#consumer-sdk-cli] A [CLI to generate a branded consumer SDK](https://github.com/ThallesP/webhooks/issues/3) for the apps that receive your webhooks. ## More adapters [#more-adapters] Outbox adapters for more databases and ORMs, next to the [Prisma adapter](/docs/prisma-adapter): - [Drizzle](https://github.com/ThallesP/webhooks/issues/4) - [Kysely](https://github.com/ThallesP/webhooks/issues/5) - [TypeORM](https://github.com/ThallesP/webhooks/issues/6) - [MikroORM](https://github.com/ThallesP/webhooks/issues/7) - [Sequelize](https://github.com/ThallesP/webhooks/issues/8) - [Knex](https://github.com/ThallesP/webhooks/issues/9) - [MongoDB](https://github.com/ThallesP/webhooks/issues/10) Until then, [write your own](/docs/write-an-adapter) in five functions. # Running workers (/docs/running-workers) `wh.worker()` returns a worker for your outbox. It only exists when `delivery` is `outbox(...)`. Create one per process. ```ts const worker = wh.worker({ pollIntervalMs: 1000, batch: 100 }); ``` ## Long-running [#long-running] `start()` runs a tick right away, then schedules the next one `pollIntervalMs` after each tick finishes. `stop()` cancels the next tick and resolves once the deliveries in flight are done. ```ts worker.start(); process.on("SIGTERM", async () => { await worker.stop(); process.exit(0); }); ``` ## From a cron job [#from-a-cron-job] `tick()` runs a single pass: claim up to `batch` due rows, deliver them, and report what happened. Call it from any scheduler instead of `start()`. ```ts title="app/api/cron/webhooks/route.ts" export async function GET() { const report = await wh.worker().tick(); return Response.json(report); } ``` A tick returns `{ claimed, delivered, retried, dead }`. Rows that failed are only tried again on a tick after their `nextAttemptAt`, so the cron interval adds to the backoff. ## What a tick does [#what-a-tick-does] For each claimed row, the worker signs the stored body with a fresh timestamp and posts it. Then: | Outcome | Row update | Hook | | ---------------------------------------- | ----------------------------------------------- | ------------- | | 2xx | `status: "delivered"` | `onDelivered` | | Failure, attempts left | `attempts + 1`, `nextAttemptAt`, `lastError` | `onRetry` | | Failure on the last attempt | `status: "dead"`, `attempts`, `lastError` | `onDead` | The number of attempts and the backoff come from [`outbox()`](/docs/reference/outbox): 20 attempts, waiting `min(1h, 30s · 2^(n−1))` after the `n`th failure. A dead endpoint gets about 13 hours of retries before its row is marked dead. ## Hooks [#hooks] ```ts const worker = wh.worker({ hooks: { onDelivered: (row) => metrics.increment("webhooks.delivered"), onRetry: (row, error) => log.warn({ url: row.url, error }, "webhook retry"), onDead: (row, error) => alerts.notify(`webhook ${row.eventId} died: ${error}`), onError: (error) => log.error(error, "outbox worker"), }, }); ``` `onError` gets errors from the adapter itself, like a failed `claim` or `complete` when the database is down. The loop keeps polling after them. A hook that throws is ignored and never stops the loop. ## Many workers [#many-workers] You can run as many workers as you like, as long as the adapter's `claim` marks the rows it returns. The [Prisma adapter](/docs/prisma-adapter) does. `memoryAdapter()` doesn't, so use it with one worker. ## Options [#options] | Option | Default | What it does | | ---------------- | ------- | --------------------------------------------------------------- | | `pollIntervalMs` | 1000 | Wait between ticks when running with `start()` | | `batch` | 100 | Rows claimed per tick | | `timeoutMs` | 10000 | Per-request timeout. A timeout counts as a failed attempt | | `hooks` | — | `onDelivered`, `onRetry`, `onDead`, `onError` | | `fetchImpl` | fetch | Swap the HTTP client, for tests or proxies | # Signing secrets (/docs/signing-secrets) Every request carries three headers from the [Standard Webhooks](https://www.standardwebhooks.com/) spec: | Header | Value | | ------------------- | --------------------------------------------------- | | `webhook-id` | The event id, like `evt_3f1c…`. Same on every retry | | `webhook-timestamp` | Unix time in seconds, fresh on every attempt | | `webhook-signature` | `v1,` followed by the base64 signature | The signature is HMAC-SHA256 over `{id}.{timestamp}.{body}`, where `body` is the exact JSON string that is sent. ## Secret formats [#secret-formats] A secret that starts with `whsec_` is base64-decoded, and the decoded bytes are the key. This is the format Svix uses. Any other non-empty string is used as-is, as UTF-8. A `whsec_` secret with nothing after the prefix, or with invalid base64, is rejected: `send()` returns `invalid signing secret for subscriber ` instead of signing with a key anyone could guess. ## Generate a secret [#generate-a-secret] Use 32 random bytes: ```sh node -e "console.log('whsec_' + require('crypto').randomBytes(32).toString('base64'))" ``` Share it with the receiver over a channel you trust. Both sides need the same value. ## Which secret is used [#which-secret-is-used] 1. The subscriber's own `secret`, if it has one. 2. Otherwise `signing.secret` from the config. With neither, `send()` returns `no signing secret for subscriber `. Giving each subscriber its own secret means one leaked secret only exposes one receiver. ```ts const wh = webhooks({ events, subscribers: async ({ subject }) => { const endpoint = await db.endpoint.findFirstOrThrow({ where: { accountId: subject } }); return [{ url: endpoint.url, secret: endpoint.secret }]; }, }); ``` In the outbox, the secret is stored on the row, so a worker signs with the secret that was current when the event was sent. ## Signing in your own code [#signing-in-your-own-code] The SDK exports the two helpers it uses, for tests and tooling: ```ts import { isValidSecret, signPayload } from "@typedhook/sdk"; isValidSecret("whsec_"); // false const signature = await signPayload(secret, "evt_1", 1791655200, body); // "v1,…" ``` # Subjects (/docs/subjects) The `subject` is the entity an event is about, in your own vocabulary: a user id, an account id, a row id. It is a string you pass to every `send()`: ```ts await wh.send({ event: "invoice.paid", subject: "acct_42", data: { invoiceId: "inv_1", amountCents: 4200 }, }); ``` ## Where it goes [#where-it-goes] - To your [subscriber resolver](/docs/subscribers#a-resolver), so you can find the subscribers of one account. - Into the signed body, next to the event name: `{ "event": "invoice.paid", "subject": "acct_42", "data": { … } }`. - Onto the outbox row, in the `subject` column. ## Required by default [#required-by-default] Multi-tenant apps almost always need it, so leaving it out is a type error. At runtime, `send()` also returns `event "…" requires a subject`, for callers without types. An empty string is rejected with `subject must be a non-empty string`. ## Making it optional [#making-it-optional] For a single-tenant app, set `subject: "optional"` in the config. Then you can leave it out, and the body carries `"subject": null`: ```ts const wh = webhooks({ events, subscribers: [{ url: "https://hooks.northwind.dev/billing" }], signing: { secret: process.env.WEBHOOK_SECRET! }, subject: "optional", }); await wh.send({ event: "invoice.paid", data: { invoiceId: "inv_1", amountCents: 4200 } }); ``` That's why consumers see `subject: string | null` in [typed payloads](/docs/typed-payloads). # Subscribers (/docs/subscribers) A subscriber is a URL, plus an optional signing secret: ```ts type Subscriber = { url: string; secret?: string }; ``` ## A static list [#a-static-list] When the receivers are fixed, pass them directly: ```ts const wh = webhooks({ events, subscribers: [ { url: "https://hooks.northwind.dev/billing" }, { url: "https://audit.northwind.dev/in", secret: process.env.AUDIT_SECRET }, ], signing: { secret: process.env.WEBHOOK_SECRET! }, }); ``` ## A resolver [#a-resolver] Most apps store subscriptions in a database. Pass a function instead. It runs on every `send()`, after `data` is validated, and returns the subscribers for that event: ```ts const wh = webhooks({ events, subscribers: async ({ event, subject, data }) => { const rows = await db.subscription.findMany({ where: { event, accountId: subject } }); return rows.map((row) => ({ url: row.url, secret: row.secret })); }, }); ``` The argument is a union keyed on `event`, so checking `event` narrows `data`. It is the validator's output, before JSON: a `z.date()` field is still a `Date` here. If the resolver throws or rejects, `send()` returns `{ ok: false, error: "subscriber resolver failed: …" }` and nothing is sent. ## Secrets per subscriber [#secrets-per-subscriber] Every subscriber needs a secret. A subscriber's own `secret` is used first, then `signing.secret`. If neither is set, `send()` returns `no signing secret for subscriber ` and sends nothing, for any subscriber. A malformed `whsec_` secret is rejected the same way. See [Signing secrets](/docs/signing-secrets). ## One event, many subscribers [#one-event-many-subscribers] Each subscriber gets its own delivery: its own request with [direct delivery](/docs/direct-delivery), its own row in the [outbox](/docs/outbox). All of them share the same event id, which is sent as the `webhook-id` header. When the resolver returns an empty list, nothing is sent and `send()` still succeeds, with `deliveries: []` or `enqueued: 0`. # Testing (/docs/testing) `memoryAdapter()` keeps the outbox in an array. Pair it with a `fetchImpl` stub and drive the worker with `tick()`, and a test runs the whole send, claim and deliver path in memory. ```ts title="billing.test.ts" import { expect, test } from "bun:test"; import { memoryAdapter, outbox, webhooks } from "@typedhook/sdk"; import { events } from "./events"; test("paying an invoice sends invoice.paid", async () => { const adapter = memoryAdapter(); const wh = webhooks({ events, subscribers: [{ url: "https://consumer.test/hook" }], signing: { secret: "test-secret" }, delivery: outbox(adapter, { backoffMs: () => 0 }), }); await wh.send({ event: "invoice.paid", subject: "acct_42", data: { invoiceId: "inv_1", amountCents: 4200 } }); expect(adapter.rows).toHaveLength(1); expect(adapter.rows[0]).toMatchObject({ eventType: "invoice.paid", status: "pending" }); const fetchImpl = async () => new Response(null, { status: 204 }); const report = await wh.worker({ fetchImpl }).tick(); expect(report).toEqual({ claimed: 1, delivered: 1, retried: 0, dead: 0 }); expect(adapter.rows[0]?.status).toBe("delivered"); }); ``` It works the same with Vitest or Jest. In Bun, `typeof fetch` has extra properties, so you may need to cast the stub. ## What the adapter exposes [#what-the-adapter-exposes] | Property | What it is | | -------- | ---------------------------------------------------------------- | | `rows` | Every row, with its current `status`, `attempts` and `lastError` | | `lastTx` | The `tx` from the last `insert`, to check `wh.with(tx)` used it | `rows` is live: it reflects every update the worker makes. ## Retries in tests [#retries-in-tests] A failed delivery is only claimed again once its `nextAttemptAt` has passed. Pass `backoffMs: () => 0` to `outbox()` so the next `tick()` retries right away, and a small `maxAttempts` to reach `dead` quickly: ```ts const wh = webhooks({ events, subscribers: [{ url: "https://consumer.test/hook" }], signing: { secret: "test-secret" }, delivery: outbox(adapter, { maxAttempts: 2, backoffMs: () => 0 }), }); const worker = wh.worker({ fetchImpl: async () => new Response(null, { status: 500 }) }); await worker.tick(); // { claimed: 1, delivered: 0, retried: 1, dead: 0 } await worker.tick(); // { claimed: 1, delivered: 0, retried: 0, dead: 1 } ``` ## Testing direct delivery [#testing-direct-delivery] Without an outbox, pass `fetchImpl` to `direct()` and read the report from `send()`: ```ts import { direct, webhooks } from "@typedhook/sdk"; const wh = webhooks({ events, subscribers: [{ url: "https://consumer.test/hook" }], signing: { secret: "test-secret" }, delivery: direct({ fetchImpl: async () => new Response(null, { status: 204 }) }), }); ``` **One worker only.** `memoryAdapter()` doesn't mark claimed rows, so two workers on it would deliver the same row twice. Use it for tests and local development. # Typed payloads (/docs/typed-payloads) The sender already knows the shape of every event. Export it, and receivers get typed payloads without depending on your schemas or your schema library. ## On the sender [#on-the-sender] `EventUnion` turns your event map into a union of payloads, one per event: ```ts title="src/events.ts" import type { EventUnion } from "@typedhook/sdk"; import { z } from "zod"; export const events = { "invoice.paid": z.object({ invoiceId: z.string(), amountCents: z.number() }), "invoice.voided": z.object({ invoiceId: z.string(), voidedAt: z.date() }), }; export type Events = EventUnion; ``` Publish `Events` from a package your consumers install, like your SDK. ## On the receiver [#on-the-receiver] Import it as a type and pass it to `verify()`. Checking `event` narrows `data`: ```ts title="app/api/webhooks/route.ts" import { createVerifier } from "@typedhook/verify"; import type { Events } from "@acme/sdk"; const verifier = createVerifier(process.env.WEBHOOK_SECRET!); export async function POST(req: Request) { const result = await verifier.verify(req); if (!result.ok) return new Response(result.reason, { status: 400 }); if (result.payload.event === "invoice.paid") { result.payload.data.amountCents; // number } return new Response(null, { status: 204 }); } ``` `import type` disappears at build time, so the receiver ships none of the sender's code. `verifyPayload()` works the same way. ## Types match the wire [#types-match-the-wire] `EventUnion` describes the JSON the receiver gets, not the values you passed to `send()`: | In your schema | In `EventUnion` | | --------------------- | ----------------- | | `z.date()` | `string` | | A class with `toJSON` | What `toJSON` returns | | `bigint`, `symbol` | `never` | | `subject` | `string \| null` | So `voidedAt` above is a `string` on the receiver. The `Jsonify` type that does this is exported too. **Types are not validation.** The verifier checks who sent the payload, not its shape. If you don't fully trust the sender, or its version can drift from yours, parse `result.payload` with a schema before using it. # Verify requests (/docs/verify-requests) `@typedhook/verify` checks the Standard Webhooks signature with WebCrypto. It reads `webhook-*` headers and falls back to `svix-*`, so it also verifies webhooks from senders that use Svix. ## Install [#install] ```package-install npm install @typedhook/verify ``` ## Verify a request [#verify-a-request] Create the verifier once, at startup. A missing or malformed secret, or a bad option, throws there, so it fails your deploy instead of every request. ```ts tab="Next.js" // app/api/webhooks/route.ts import { createVerifier } from "@typedhook/verify"; const verifier = createVerifier(process.env.WEBHOOK_SECRET!); export async function POST(req: Request) { const result = await verifier.verify(req); if (!result.ok) return new Response(result.reason, { status: 400 }); result.id; // "evt_3f1c…", same on every retry result.timestamp; // Date result.payload; // { event, subject, data } return new Response(null, { status: 204 }); } ``` ```ts tab="Hono" // src/index.ts import { Hono } from "hono"; import { createVerifier } from "@typedhook/verify"; const app = new Hono(); const verifier = createVerifier(process.env.WEBHOOK_SECRET!); app.post("/webhooks", async (c) => { const result = await verifier.verify(c.req.raw); if (!result.ok) return c.text(result.reason, 400); return c.body(null, 204); }); export default app; ``` ```ts tab="Workers" // src/index.ts import { createVerifier } from "@typedhook/verify"; export default { async fetch(req: Request, env: { WEBHOOK_SECRET: string }) { const verifier = createVerifier(env.WEBHOOK_SECRET); const result = await verifier.verify(req); if (!result.ok) return new Response(result.reason, { status: 400 }); return new Response(null, { status: 204 }); }, }; ``` ```ts tab="Express" // src/server.ts import express from "express"; import { createVerifier } from "@typedhook/verify"; const app = express(); const verifier = createVerifier(process.env.WEBHOOK_SECRET!); app.post("/webhooks", express.raw({ type: "application/json" }), async (req, res) => { const result = await verifier.verifyPayload(req.body, req.headers); res.status(result.ok ? 204 : 400).end(); }); ``` **Express and Fastify: pass the raw body.** Parsed and re-serialized JSON changes the bytes and breaks the signature. Use `express.raw({ type: "application/json" })` and `verifier.verifyPayload(req.body, req.headers)`. ## Failure reasons [#failure-reasons] `verify()` resolves to `{ ok: false, reason }` instead of throwing. Checks run in this order, and the body is only read once the headers pass. | Reason | When | Stage | | ---------------------------- | --------------------------------------------------------------------------------- | --------- | | `missing_headers` | No `webhook-id`, `-timestamp` or `-signature` (or `svix-*`) | headers | | `invalid_id` | The id contains a dot, which the spec forbids | headers | | `invalid_timestamp` | The timestamp is not an integer of seconds | headers | | `timestamp_out_of_tolerance` | More than ±300s from now. Change it with `toleranceSec` | headers | | `unreadable_body` | The request body was already read or the stream aborted | body | | `invalid_secret` | The secret could not be imported as an HMAC key | signature | | `invalid_signature` | None of the `v1,` signatures match (up to 8 are tried, for key rotation) | signature | | `invalid_json` | Signature is valid but the body is not JSON | JSON | ## Handle duplicates [#handle-duplicates] Senders retry until they get a 2xx, so the same webhook can arrive twice, or after a newer one. `result.id` is the same on every retry. Record it and skip ids you have already processed. ```ts title="app/api/webhooks/route.ts" const result = await verifier.verify(req); if (!result.ok) return new Response(result.reason, { status: 400 }); const fresh = await db.processedWebhook.createMany({ data: [{ id: result.id }], skipDuplicates: true, }); if (fresh.count === 0) return new Response(null, { status: 204 }); // seen it await handle(result.payload); return new Response(null, { status: 204 }); ``` ## Secrets [#secrets] Secrets that start with `whsec_` are base64-decoded, like Svix secrets. Anything else is used as plain text. An empty secret, or `whsec_` followed by invalid base64, throws in `createVerifier()`. **The verifier checks who sent it, not its shape.** `verify()` types the payload but does not validate it. If you do not fully trust the sender, parse `result.payload` with a schema. # Write an adapter (/docs/write-an-adapter) An adapter tells the outbox where rows live. It is an object with five functions: ```ts import type { OutboxAdapter, OutboxRow } from "@typedhook/sdk"; interface OutboxAdapter { insert: (rows: OutboxRow[], tx?: Tx) => Promise; claim: (limit: number, now: Date) => Promise; complete: (rowId: string) => Promise; retry: (rowId: string, attempts: number, nextAttemptAt: Date, lastError: string) => Promise; dead: (rowId: string, attempts: number, lastError: string) => Promise; } ``` `Tx` is your transaction type. It's what `wh.with(tx)` accepts, so type it precisely. ## The row [#the-row] | Field | Type | Notes | | --------------- | --------------------------------------- | -------------------------------------------------- | | `id` | `string` | `obx_…`, primary key | | `eventId` | `string` | `evt_…`, sent as `webhook-id` | | `eventType` | `string` | The event name | | `subject` | `string \| null` | See [Subjects](/docs/subjects) | | `body` | `string` | The JSON body, sent verbatim | | `url` | `string` | The subscriber's URL | | `secret` | `string \| null` | The signing secret | | `attempts` | `number` | Failed attempts so far | | `nextAttemptAt` | `Date` | When the row is due | | `status` | `"pending" \| "delivered" \| "dead"` | | | `lastError` | `string \| null` | The last failure, like `endpoint responded 503` | | `createdAt` | `Date` | | Store `body` and `secret` exactly as given. The worker signs `body` as-is, and any change breaks the signature. ## What each function must do [#what-each-function-must-do] | Function | Contract | | ---------- | ------------------------------------------------------------------------------------------------------ | | `insert` | Write all rows. Use `tx` when it's passed, your own connection when it isn't. Reject on failure. | | `claim` | Return up to `limit` rows with `status: "pending"` and `nextAttemptAt <= now`, oldest due first. | | `complete` | Set `status` to `"delivered"`. | | `retry` | Set `attempts`, `nextAttemptAt` and `lastError`. Keep `status` pending. | | `dead` | Set `status` to `"dead"`, with `attempts` and `lastError`. | `insert` is the only call that runs inside your transaction. When it rejects, `send()` rejects too, so your transaction rolls back. ## Claiming with more than one worker [#claiming-with-more-than-one-worker] If you run more than one worker, `claim` must mark the rows it returns so other workers skip them, for example by pushing `nextAttemptAt` forward. A lock alone isn't enough: it's released when `claim` returns, before the delivery happens. On Postgres, the [Prisma adapter](/docs/prisma-adapter) does it in one statement: ```sql UPDATE "WebhookOutbox" SET "nextAttemptAt" = $1 -- now + claim timeout WHERE "id" IN ( SELECT "id" FROM "WebhookOutbox" WHERE "status" = 'pending' AND "nextAttemptAt" <= $2 -- now ORDER BY "nextAttemptAt" LIMIT $3 FOR UPDATE SKIP LOCKED ) RETURNING * ``` If a worker dies after claiming, the row becomes due again once that timeout passes, and another worker sends it. That's why delivery is at least once. Pick a timeout longer than the worker's `timeoutMs`. ## Reference implementations [#reference-implementations] - [`packages/prisma/src/index.ts`](https://github.com/ThallesP/webhooks/blob/main/packages/prisma/src/index.ts): Postgres through Prisma. - [`memoryAdapter()` in `packages/sdk/src/outbox.ts`](https://github.com/ThallesP/webhooks/blob/main/packages/sdk/src/outbox.ts): an array, for tests. Adapters for Drizzle, Kysely, TypeORM, MikroORM, Sequelize, Knex and MongoDB are [planned](/docs/roadmap#more-adapters).