Fastify
Verify webhooks in Fastify with FastifyWebAdapter — gated routes in a self-contained scope that captures the raw body and sets req.postel.
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.
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 singlewebhookkey inside the route-options object rather than as a separate argument — so what you pass stays a plain FastifyRouteShorthandOptionsthat reads like any otherfastify.post(...)call. The adapter readswebhook, 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 + providerbindJwks() 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) })));