Get started

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/core
npm install @postel/core
yarn add @postel/core
bun add @postel/core

Zero 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.

lib/postel.ts
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 typed

Verify 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:

app/api/webhooks/github/route.ts
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);
app.ts
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 });
});
app.ts
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 });
});
app.ts
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:

webhooks.controller.ts
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:

scripts/send-fixture.ts
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); // 200

signFixture 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.

lib/postel.ts
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.

orders.ts
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.

On this page