Quickstart
Zero to a verified inbound webhook and a signed outbound one, in a few minutes. Node, Bun, or Deno.
Postel has two independent halves. This page gets each one working from scratch — do the one you need now, or both. Everything below is TypeScript you can run today; other languages are on the roadmap.
Receive a webhook
Verify a Standard Webhooks-signed request from Stripe, GitHub, or any compliant producer.
Install
pnpm add @postel/corenpm install @postel/coreyarn add @postel/corebun add @postel/coreZero runtime dependencies. If you use a web framework, add its adapter too — @postel/hono, @postel/express, @postel/fastify, @postel/nestjs, or @postel/http for any Fetch runtime.
Configure a source
Once per process, at module scope. One entry per webhook source, keyed by the producer's name.
import { Postel, Secret } from "@postel/core";
import { config } from "./config.js";
export const postel = Postel({
inbound: {
github: { verify: Secret(config.githubWebhookSecret) },
// stripe: { verify: Secret(config.stripeWebhookSecret) },
},
});Secret(...) is a verifier strategy — swap in Keyset({ jwksUri }) for JWKS, or pass an array for key rotation. postel.inbound.github is fully typed; sources you didn't configure don't exist on the type.
Declaring the config separately? Keep the literal.
postel.inbound / postel.outbound exist on the instance type only because the factory reads the config's literal shape. Annotating a config with : PostelConfig widens that literal and the slots vanish from the type. Inline the object (as above), or — if you keep it in its own const — wrap it in definePostelConfig(...) (or write as const satisfies PostelConfig) so the literal is preserved:
import { definePostelConfig, Postel, Secret } from "@postel/core";
const config = definePostelConfig({
inbound: { github: { verify: Secret(process.env.GH_SECRET!) } },
});
export const postel = Postel(config); // postel.inbound.github is still typedVerify a request
The verifier runs over the exact received bytes — never re-parse and re-serialize the body first (here's why). Pick your framework; each adapter is a thin gate over the same verifier, so your handler only runs once the signature checks out.
Any Fetch runtime — Next.js Route Handlers, Bun, Deno:
import { fetchWebhook } from "@postel/http";
import { postel } from "@/lib/postel";
const handler = fetchWebhook(postel.inbound.github, {
onVerified: async ({ event }) => {
console.log("received:", event.type, event.data);
},
});
export const POST = (req: Request) => handler(req);import { Hono } from "hono";
import { verifyWebhook, POSTEL_CONTEXT_KEY } from "@postel/hono";
import { postel } from "./lib/postel";
const app = new Hono();
app.post("/webhooks/github", verifyWebhook(postel.inbound.github), (c) => {
const { event } = c.get(POSTEL_CONTEXT_KEY); // verified · raw bytes intact
return c.json({ ok: true, type: event.type });
});import express from "express";
import { verifyWebhook } from "@postel/express";
import { postel } from "./lib/postel";
const app = express();
// verifyWebhook mounts express.raw() + the gate — don't put express.json() ahead of it
app.post("/webhooks/github", verifyWebhook(postel.inbound.github), (req, res) => {
res.json({ ok: true, type: req.postel?.event.type });
});import Fastify from "fastify";
import { fastifyPostel, verifyWebhook } from "@postel/fastify";
import { postel } from "./lib/postel";
const app = Fastify();
await app.register(fastifyPostel); // raw-body parser; register on a webhook-only scope
app.post(
"/webhooks/github",
{ preHandler: verifyWebhook(postel.inbound.github) },
async (req) => ({ ok: true, type: req.postel?.event.type }),
);Boot with rawBody: true, register PostelModule.forRoot(postel), then guard the route:
import { Controller, Post, UseGuards } from "@nestjs/common";
import { WebhookGuard, Event } from "@postel/nestjs";
import type { WebhookEvent } from "@postel/core";
@Controller("webhooks")
export class WebhooksController {
@Post("github")
@UseGuards(WebhookGuard("github"))
handle(@Event() event: WebhookEvent) {
return { ok: true, type: event.type };
}
}On a verification failure the gate short-circuits with the right HTTP status and your handler never runs. Each adapter is covered in full under Web adapters.
Try it locally
You need a signed request to verify. The library ships a fixture signer for exactly this:
import { signFixture } from "@postel/core";
import { config } from "../lib/config.js";
const fixture = await signFixture({
secret: config.githubWebhookSecret,
payload: { type: "issue.opened", data: { id: 42 } },
});
const res = await fetch("http://localhost:3000/webhooks/github", {
method: "POST",
headers: fixture.headers,
body: fixture.body,
});
console.log(res.status); // 200signFixture is for tests — production signing is the sender's job, next.
Send a webhook
Deliver Standard Webhooks to your customers' endpoints, with a transactional outbox, retries, and signing handled for you.
Configure the sender
Pass an outbound block to the same factory. InMemoryStorage gets you running immediately; point it at a database for production.
import { Postel, InMemoryStorage, HmacV1, ExponentialBackoff } from "@postel/core";
export const postel = Postel({
outbound: {
storage: InMemoryStorage(), // swap for a DB adapter in production
signing: HmacV1(), // or Ed25519V1a() for asymmetric + JWKS
retryPolicy: ExponentialBackoff({ maxAttempts: 8 }),
},
});For durable delivery, swap InMemoryStorage() for a storage adapter — standalone @postel/pg / @postel/sqlite / @postel/mysql, or your existing Kysely / Drizzle / Prisma / TypeORM / MikroORM instance.
Register an endpoint
An endpoint is a receiver URL plus the event types it should get. Postel mints its signing secret automatically.
await postel.outbound.endpoints.create({
url: "https://customer.example.com/webhooks",
types: ["order.*"],
});Fan-out is automatic: one send() reaches every endpoint whose types match the event.
Send inside your transaction
send() is an outbox INSERT — pass it your transaction handle and the webhook is queued only if your business write commits. A worker delivers it after commit, so you never block on the network inside the transaction.
await db.tx(async (tx) => {
await db.orders.insert({ id: "ord_123", status: "paid" }, { tx });
await postel.outbound.send(
{ type: "order.created", data: { id: "ord_123" } },
{ tx }, // commits or rolls back atomically
);
});That's the transactional outbox — no broker, no dispatcher process, no dual-write race.
Where to next
Receiving — the things that bite every integration eventually:
- Raw bytes — the single most common silent failure. Read this before you ship.
- Key rotation — rotate secrets with zero downtime.
- Deduplication — webhooks are at-least-once; here's the atomic helper.
Sending — on the way to production:
- Storage — pick a durable adapter, or bring your own.
- Retries & backoff — circuit breaker, dead-letter, auto-disable.
- Replay — re-emit historical messages by id, endpoint, or filter.
Not sure Postel fits your stack? Run the six-line filter.