Type-safe webhooks for TypeScript. Uses the Standard Webhooks format.
| Package | Use it to |
|---|---|
@typedhook/sdk |
Send webhooks |
@typedhook/verify |
Verify webhooks you receive (zero deps) |
@typedhook/prisma |
Store the outbox in Postgres with Prisma |
@typedhook/drizzle |
Store the outbox with Drizzle (Postgres, MySQL, SQLite) |
@typedhook/kysely |
Store the outbox with Kysely (Postgres, MySQL, SQLite) |
@typedhook/typeorm |
Store the outbox with TypeORM (Postgres, MySQL) |
@typedhook/mikro-orm |
Store the outbox with MikroORM (Postgres, MySQL, SQLite) |
@typedhook/sequelize |
Store the outbox with Sequelize (Postgres, MySQL, SQLite) |
@typedhook/knex |
Store the outbox with Knex (Postgres, MySQL, SQLite) |
@typedhook/mongodb |
Store the outbox in MongoDB |
npm install @typedhook/sdkimport { webhooks } from "@typedhook/sdk";
import { z } from "zod"; // or valibot, arktype (any Standard Schema)
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);subscriberscan be a function or a static array of{ url, secret? }. A subscriber'ssecretoverridessigning.secret.subjectis the entity the event is about (a user, account or row id). It's passed tosubscribersand included in the signed body. It's required unless you setsubject: "optional".send()returns{ ok: false, error }for invalid data, unknown events or a failing resolver instead of throwing.- By default webhooks are sent right away and retried in memory (
direct({ maxAttempts: 5 })). Retries are lost if the process crashes. Use the outbox if that matters. - Redirects are not followed. A 3xx counts as a failed delivery.
The outbox saves events in your database, in the same transaction as your data. A worker sends them later and retries until they succeed.
npm install @typedhook/sdk @typedhook/prismaimport { webhooks, outbox } from "@typedhook/sdk";
import { prismaOutboxAdapter } from "@typedhook/prisma";
import type { Prisma } from "@prisma/client";
const wh = webhooks({
events,
subscribers,
signing: { secret: process.env.WEBHOOK_SECRET! },
delivery: outbox(prismaOutboxAdapter<Prisma.TransactionClient>(prisma), { maxAttempts: 20 }),
});
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 },
});
});
// In a long-running process, or call wh.worker().tick() from a cron
wh.worker({ pollIntervalMs: 1000, batch: 100 }).start();wh.with() and wh.worker() only exist when delivery is outbox(...). Using them otherwise is a type error.
If the database insert fails, send() throws, so your transaction rolls back.
See packages/prisma for the Prisma schema and options.
Each adapter's README has the schema to copy, how to pass your transaction to wh.with(), and its options:
| ORM / driver | Package |
|---|---|
| Prisma | packages/prisma |
| Drizzle | packages/drizzle |
| Kysely | packages/kysely |
| TypeORM | packages/typeorm |
| MikroORM | packages/mikro-orm |
| Sequelize | packages/sequelize |
| Knex | packages/knex |
| MongoDB | packages/mongodb |
For anything else, write your own adapter:
interface OutboxAdapter<Tx = unknown> {
insert: (rows: OutboxRow[], tx?: Tx) => Promise<void>;
claim: (limit: number, now: Date) => Promise<OutboxRow[]>; // pending rows due by `now`
complete: (rowId: string) => Promise<void>;
retry: (rowId: string, attempts: number, nextAttemptAt: Date, lastError: string) => Promise<void>;
dead: (rowId: string, attempts: number, lastError: string) => Promise<void>;
}If you run more than one worker, claim must mark the rows it returns (for example, push next_attempt_at forward) so other workers skip them. A lock alone is released before delivery happens. memoryAdapter() is included for tests.
npm install @typedhook/verifyWorks on Node 20+, Bun, Deno, Cloudflare Workers and edge runtimes. It also accepts svix-* headers.
import { createVerifier } from "@typedhook/verify";
const verifier = createVerifier(process.env.WEBHOOK_SECRET!);
// Next.js, Hono, Remix, Workers
export async function POST(req: Request) {
const result = await verifier.verify(req);
if (!result.ok) return new Response(result.reason, { status: 400 });
// result.id, result.payload
return new Response(null, { status: 204 });
}
// Express: use the raw body, parsed JSON breaks the signature
app.post("/hook", express.raw({ type: "application/json" }), async (req, res) => {
const result = await verifier.verifyPayload(req.body, req.headers);
res.status(result.ok ? 204 : 400).end();
});verify() never throws. On failure, result.reason is one of missing_headers, invalid_id, invalid_timestamp, timestamp_out_of_tolerance (±300s, change with toleranceSec), invalid_signature, invalid_secret, unreadable_body or invalid_json. A bad secret or option throws in createVerifier(), so it fails at startup.
Handle 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've already seen.
Secrets that start with whsec_ are base64-decoded. Any other secret is used as plain text.
Export the event types from the sender and import them as types in the consumer:
// sender
import type { EventUnion } from "@typedhook/sdk";
export type Events = EventUnion<typeof events>;
// consumer
import type { Events } from "acme-sdk";
const result = await verifier.verify<Events>(req);
if (result.ok && result.payload.event === "invoice.paid") {
result.payload.data.amountCents; // number
}Types match the JSON on the wire, so a z.date() field is a string. The verifier checks who sent the payload, not its shape. Validate it with a schema if you don't fully trust the sender.
bun install
bun test
bun run typecheckAdapter tests run against real databases when these are set, and skip otherwise (SQLite always runs in-process):
TYPEDHOOK_TEST_POSTGRES_URL=postgres://postgres:pg@127.0.0.1:5432/webhooks_test
TYPEDHOOK_TEST_MYSQL_URL=mysql://root:mysql@127.0.0.1:3306/webhooks_test
TYPEDHOOK_TEST_MONGO_URL="mongodb://127.0.0.1:27017/?replicaSet=rs0&directConnection=true"CI starts all three; see .github/workflows/ci.yml.
MIT