Web adapters

Overview

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

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.

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
Any Fetch runtime (Deno, custom, …)@postel/httpfetchWebhook(source)
Next.js Route Handlers@postel/nextjs⏳ stubUse @postel/http meanwhile
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