Web adapters

Overview

Wire Postel's verification gate into your framework or runtime. One core — @postel/http — bound idiomatically per framework.

View as Markdown

Every web adapter is a thin gate over @postel/http: it reads the raw request bytes, runs the verifier(s) you configured on a source, maps any PostelError to the right HTTP status, optionally dedup-acks, and only then hands control to your handler. You keep writing a normal handler — you don't re-implement verify → error-mapping → ack in every route.

The error→status policy, byte preservation, and the dedup-ack signal live once in @postel/http; each adapter binds them to its framework's idiom (middleware, preHandler, guard), so they can't drift. A non-PostelError (e.g. a programming bug) bubbles as 5xx — never miscast as a 4xx.

The shape every adapter shares

XxxWebAdapter(postel, app) binds to your app and exposes bindings grouped by intent — present only for the config slots you set:

  • hwa.inbound.<source>.post(route, handler, opts?) — register a gated route. Raw bytes are verified before your handler runs, with the typed result on the request context (c.var.postel / req.postel). The source key is type-checked against your config. For a non-POST delivery (some providers use PUT/PATCH), use .on(method, route, handler, opts?).post is sugar for .on("POST", …). Bodyless verbs (GET/HEAD) aren't offered: the gate verifies a signature over the body.
  • hwa.outbound.bindJwks(route?, provider?) — publish your public keys (defaults to /.well-known/webhooks-keys + postel.outbound.keys.publicJwks()).
  • hwa.admin.bindAdminRoutes(prefix, opts) — mount the @postel/admin control plane.

Prefer to own the routing yourself? The facade is sugar over the low-level primitives every package still exports: verifyWebhook(source, opts?) (the framework-native gate) and withWebhook(source, handler, opts?) (gate + handler folded into one). NestJS is the exception — it stays DI-idiomatic with a WebhookGuard rather than a routing facade.

Next.js is the other exception. App Router routes files, not an app object, so NextjsWebAdapter(postel) takes no app and its bindings return Route Handlers keyed by HTTP method instead of registering a path: nwa.inbound.<source>.post(handler, opts?) returns { POST }, nwa.outbound.bindJwks(provider?) returns { GET }, nwa.admin.bindAdminRoutes(opts) returns { GET, POST, PUT, PATCH, DELETE } — each spread directly as the route file's exports. It keeps the low-level withWebhook(source, handler, opts?) primitive; there's no verifyWebhook/getVerified pair since a Fetch Request isn't mutated with a stashed result the way a framework's request/context object is. See Next.js.

Adding your own middleware

The facade owns the route, but you keep your framework's native middleware story — no special API needed:

  • Express / Hono — layer middleware on the path with app.use("/webhooks/vendor", …), registered before the facade .post (middleware runs in registration order). The gate parses the body, so path middleware sees the request pre-verification — ideal for auth, rate-limit, or logging.
  • Fastify — pass route options to .post: onRequest runs before the gate, preHandler after it (with the result on req.postel). See Fastify.

Want the gate as a standalone middleware to wire entirely by hand? verifyWebhook(source) / withWebhook(source, handler) are exported from every package — read the verified result on that primitive path with getVerified(reqOrCtx) (it throws if the gate didn't run). Facade routes are already typed (c.var.postel / req.postel, narrowed to the source's schema output), so they don't need it.

OpenAPI / route docs

The facade binds verification, not documentation — its routes don't auto-populate an OpenAPI/Swagger spec, and the source schema (a Standard Schema for verify + types) is a different slot from your framework's route schema. How it interacts:

  • Fastify@fastify/swagger discovers the route (it's in the route table) but reads Fastify's route schema, not Postel's. Pass schema through the .post / .on route options and it flows to the spec (reuse the same zod object with fastify-type-provider-zod).
  • NestJS — unaffected: @nestjs/swagger reads your controllers + DTOs; the WebhookGuard doesn't touch it.
  • Hono@hono/zod-openapi only documents routes declared through its own createRoute(...) API, so facade-bound routes are invisible to it. To put a webhook route in the spec, define it with @hono/zod-openapi and apply the gate as middleware (verifyWebhook(postel.inbound.<source>)).
  • Express — OpenAPI is manual (swagger-jsdoc / tsoa) regardless of the facade.

We deliberately don't bridge the two slots: auto-converting Standard Schema → JSON Schema would couple @postel/core to a specific library and break its zero-dependency guarantee.

Pick your target

Status

Framework / runtimePackageShips?Form
Hono@postel/honoHonoWebAdapter routing facade + primitives
Express@postel/expressExpressWebAdapter facade + express.raw() gate
Fastify@postel/fastifyFastifyWebAdapter facade + raw-body plugin
NestJS@postel/nestjsModule + WebhookGuard + decorators
Next.js Route Handlers@postel/nextjsNextjsWebAdapter route-handler bindings
Any Fetch runtime (Deno, custom, …)@postel/httpfetchWebhook(source)
Bun.serve@postel/bun⏳ stubUse @postel/http meanwhile

Both directions

Adapters also mount the outbound HTTP surface: hwa.outbound.bindJwks() publishes your current public keys (see key rotation), and hwa.admin.bindAdminRoutes() mounts the @postel/admin control plane (natively on Hono; via fetchToExpress / fetchToFastify on Node).

On this page