Web adapters

Fastify

Verify webhooks in Fastify with FastifyWebAdapter — gated routes in a self-contained scope that captures the raw body and sets req.postel.

lib/postel.ts
import { Postel, Secret } from "@postel/core";
import { config } from "./config.js";

export const postel = Postel({
  inbound: {
    vendor: {
      verify: Secret(config.webhookSecret),
    },
  },
});

Routing facade

FastifyWebAdapter(postel, app) registers gated routes by source key. Verification needs the exact received bytes, so the adapter registers everything it binds inside its own encapsulated scope with a raw-Buffer body parser — your app's JSON parsing stays intact, and there's no plugin to wire up.

app.ts
import Fastify from "fastify";
import { FastifyWebAdapter } from "@postel/fastify";
import { postel } from "./lib/postel";

const app = Fastify();
const fwa = FastifyWebAdapter(postel, app);

fwa.inbound.vendor.post("/webhooks/vendor", async (req) => ({
  ok: true,
  type: req.postel.event.type,
}));

Source keys are type-checked. On failure the gate replies with the mapped status; a non-PostelError bubbles to Fastify's error handler (5xx). The adapter's routes register when its scope loads (at app.ready() / listen()), so bind them before you call listen.

Other methods

.post is sugar for .on("POST", …). Bind another body-bearing verb (PUT/PATCH) explicitly; .on accepts the same route-options overload (below):

fwa.inbound.vendor.on("PUT", "/webhooks/vendor", async () => ({ ok: true }));

Route options & middleware

.post mirrors Fastify's own fastify.post: pass a route-options object and the gate is injected as the first preHandler for you. Anything Fastify routes accept works — onRequest, preHandler, schema, bodyLimit, config, … — so you attach whatever you'd attach to a normal route.

fwa.inbound.vendor.post(
  "/webhooks/vendor",
  {
    onRequest: rateLimit,              // runs before the gate — pre-verify (body not parsed yet)
    preHandler: attachTenant,          // runs after the gate — req.postel is set
    bodyLimit: 1_048_576,              // any route option is forwarded to Fastify untouched
    webhook: { dedup: { ttl: "1h" } }, // Postel gate options (see note)
  },
  async (req) => ({ ok: true, type: req.postel.event.type }),
);

Because the gate runs at preHandler, Fastify's lifecycle gives you the split for free: onRequest = before verification, preHandler = after verification (the verified result is already on req.postel).

Why gate options live under webhook. Postel's gate options (dedup, successStatus) ride under a single webhook key inside the route-options object rather than as a separate argument — so what you pass stays a plain Fastify RouteShorthandOptions that reads like any other fastify.post(...) call. The adapter reads webhook, strips it, and forwards everything else to Fastify untouched. Not passing route options? Give them as the third argument instead: fwa.inbound.vendor.post(route, handler, { dedup }).

JWKS

fwa.outbound.bindJwks();                         // GET /.well-known/webhooks-keys
fwa.outbound.bindJwks("/keys", customProvider);  // custom route + provider

bindJwks() defaults the provider to postel.outbound.keys.publicJwks(); available when an outbound slot is configured.

Admin

fwa.admin.bindAdminRoutes("/admin", { authorize: (req) => checkAdminToken(req) });

Low-level primitives

The facade wraps the withWebhook / verifyWebhook preHandlers and the fetchToFastify bridge. On this manual path you install the raw-body parser yourself with fastifyPostel, on the instance (or encapsulated scope) serving your webhook routes — the facade does this in its own scope; here you're wiring it by hand:

import { fastifyPostel, verifyWebhook, fetchToFastify } from "@postel/fastify";
import { adminRouter } from "@postel/admin";

await app.register(fastifyPostel); // raw-Buffer parser for the verifyWebhook routes in this scope

app.post("/webhooks/vendor", { preHandler: verifyWebhook(postel.inbound.vendor) }, async () => "ok");
app.all("/admin/*", fetchToFastify(adminRouter(postel, { authorize: (req) => checkAdminToken(req) })));

On this page