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-POSTdelivery (some providers usePUT/PATCH), use.on(method, route, handler, opts?)—.postis 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/admincontrol 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:onRequestruns before the gate,preHandlerafter it (with the result onreq.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/swaggerdiscovers the route (it's in the route table) but reads Fastify's routeschema, not Postel's. Passschemathrough the.post/.onroute options and it flows to the spec (reuse the same zod object withfastify-type-provider-zod). - NestJS — unaffected:
@nestjs/swaggerreads your controllers + DTOs; theWebhookGuarddoesn't touch it. - Hono —
@hono/zod-openapionly documents routes declared through its owncreateRoute(...)API, so facade-bound routes are invisible to it. To put a webhook route in the spec, define it with@hono/zod-openapiand 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
Hono
HonoWebAdapter(postel, app).inbound.<source>.post(...).
Express
ExpressWebAdapter — express.raw() + gate; sets req.postel.
Fastify
fastifyPostel raw-body plugin + FastifyWebAdapter.
NestJS
PostelModule + WebhookGuard + @Event().
Next.js
NextjsWebAdapter — typed Route Handler bindings over @postel/http.
Bun
Bun.serve is Fetch-native — use fetchWebhook directly.
Status
| Framework / runtime | Package | Ships? | Form |
|---|---|---|---|
| Hono | @postel/hono | ✅ | HonoWebAdapter routing facade + primitives |
| Express | @postel/express | ✅ | ExpressWebAdapter facade + express.raw() gate |
| Fastify | @postel/fastify | ✅ | FastifyWebAdapter facade + raw-body plugin |
| NestJS | @postel/nestjs | ✅ | Module + WebhookGuard + decorators |
| Next.js Route Handlers | @postel/nextjs | ✅ | NextjsWebAdapter route-handler bindings |
| Any Fetch runtime (Deno, custom, …) | @postel/http | ✅ | fetchWebhook(source) |
| Bun.serve | @postel/bun | ⏳ stub | Use @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).