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.
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
Route Handlers are Fetch-native — use fetchWebhook directly.
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 |
| Any Fetch runtime (Deno, custom, …) | @postel/http | ✅ | fetchWebhook(source) |
| Next.js Route Handlers | @postel/nextjs | ⏳ stub | Use @postel/http meanwhile |
| 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).