From a signing-only library
standardwebhooks or svix's verify() to the Postel inbound gate — same headers, more failure modes named.
If you're only receiving webhooks today, you're probably using standardwebhooks or the svix package's Webhook.verify() directly in a route handler. Both implement the same Standard Webhooks spec Postel does, so this is the smallest migration in this section: the wire format doesn't change, only how much the library does for you once verification succeeds.
API mapping
standardwebhooks / svix | Postel |
|---|---|
new Webhook(secret) | Secret(secret) passed as a source's verify |
wh.verify(payload, headers) | postel.inbound.<source>.verify(rawBytes, headers) |
| Returns the parsed payload on success | Returns { event, matchedVerifierIndex } — event.data is the parsed payload |
Throws a single WebhookVerificationError on any failure | Throws a typed subclass per failure: MalformedHeader, TimestampTooOld, SignatureInvalid, UnknownKeyId — see what verify throws |
webhook-id / webhook-timestamp / webhook-signature (svix-* aliases accepted by the svix package) | Same three headers, same names — no header renaming needed |
| One secret, swapped by hand for rotation | verify: [Secret(current), Secret(previous)] — an array, tried in order, with matchedVerifierIndex telling you which one matched |
| No JWKS / asymmetric option | PublicKey(...) or Keyset({ jwksUri }) for Ed25519 (v1a) verification, including automatic key refresh |
| No idempotency handling — you write your own dedup, or skip it | dedup + dedupTtl config — see Deduplication |
Hand-wired body plumbing per framework (express.raw(), reading a Request stream, …) | A web adapter (HonoWebAdapter, ExpressWebAdapter, FastifyWebAdapter, NestjsWebAdapter) does the raw-body plumbing and error-to-status mapping for you |
| No payload schema validation | Optional schema (zod/valibot/arktype via Standard Schema) types and validates event.data |
Before
import { Webhook, WebhookVerificationError } from "standardwebhooks";
const wh = new Webhook(process.env.WEBHOOK_SECRET);
app.post("/webhooks/vendor", express.raw({ type: "application/json" }), (req, res) => {
try {
const event = wh.verify(req.body, req.headers);
// handle event
res.sendStatus(200);
} catch (err) {
if (err instanceof WebhookVerificationError) return res.sendStatus(401);
throw err;
}
});After
import { Postel, Secret } from "@postel/core";
export const postel = Postel({
inbound: {
vendor: { verify: Secret(process.env.WEBHOOK_SECRET!) },
},
});import { ExpressWebAdapter } from "@postel/express";
import { postel } from "./lib/postel";
ExpressWebAdapter(postel, app).inbound.vendor.post("/webhooks/vendor", (req, res) => {
req.postel.event; // verified and (if you add a schema) typed
res.sendStatus(200);
});Or keep your own route and call verify directly — no framework adapter required:
import { PostelError } from "@postel/core";
import { headersFromNode } from "@postel/http/node";
import { statusForError } from "@postel/http";
app.post("/webhooks/vendor", express.raw({ type: "application/json" }), async (req, res) => {
try {
const { event } = await postel.inbound.vendor.verify(req.body, headersFromNode(req.headers));
res.sendStatus(200);
} catch (err) {
if (err instanceof PostelError) return res.sendStatus(statusForError(err));
throw err;
}
});headersFromNode flattens Node's string | string[] header values into the Record<string, string> shape verify expects, and statusForError is the same code→status mapping the framework gates use.
Migration steps
- Add
@postel/core(and a framework adapter package, if you want the routing facade instead of hand-wiring). - Replace
new Webhook(secret)with aPostel({ inbound: { <source>: { verify: Secret(secret) } } })factory at module scope. - Replace
wh.verify(payload, headers)withpostel.inbound.<source>.verify(rawBytes, headers), and split your singlecatchblock into the specific error subclasses you care about distinguishing (most teams only need to tellSignatureInvalid/TimestampTooOldapart from a 500-worthy bug). - Keep whatever raw-body handling you already have (
express.raw(), etc.) — see Raw bytes if you're not 100% sure your framework isn't re-serializing the body first. This is the one step worth double-checking even though nothing above forces you to touch it. - If you were rotating secrets by editing an environment variable and redeploying, switch to
verify: [Secret(current), Secret(previous)]and drop the previous one on your own schedule instead of coordinating with the producer. - Optionally attach a
schemato get typed, validatedevent.datainstead ofunknown.
What you give up
Honestly: very little, and mostly in the other direction — this migration adds capability (structured errors, JWKS, multi-secret rotation, dedup, framework adapters) rather than removing it, since standardwebhooks and svix.Webhook are narrowly scoped to signature verification. The trade is a larger dependency: @postel/core is more surface than a single Webhook class, even though it has zero runtime dependencies of its own. If verification is genuinely all you need — no rotation, no JWKS, no dedup — staying on the minimal library is a legitimate choice and this migration buys you little. It starts paying off the moment you need any one of those things, or you also want to send webhooks and would rather not run two different libraries for the two halves.
Read next
- Verify a signed request — the full recipe, including custom verifiers.
- Raw bytes — the most common silent failure after a framework migration.
- Web adapters — Hono, Express, Fastify, NestJS routing facades.