Verify a signed request
The basic receiver recipe — configure once at module scope, call verify() from your handler.
The full receiver surface is two calls: a factory at module scope, and a verify call inside your handler. This page covers the basic shape. Edge cases — rotation, JWKS, dedup, framework integration — get their own pages.
The recipe
import { Postel, Secret } from "@postel/core";
import { config } from "./config.js";
export const postel = Postel({
inbound: {
vendor: {
verify: Secret(config.vendorWebhookSecret),
},
},
});import { postel } from "@/lib/postel";
import {
SignatureInvalid,
TimestampTooOld,
MalformedHeader,
} from "@postel/core";
export async function POST(req: Request) {
const body = new Uint8Array(await req.arrayBuffer()); // <- raw bytes; see /docs/concepts/raw-bytes
const headers = Object.fromEntries(req.headers);
try {
const { event, matchedVerifierIndex } = await postel.inbound.vendor.verify(body, headers);
// event.type, event.data, event.timestamp — all parsed for you.
return new Response("ok", { status: 200 });
} catch (err) {
if (err instanceof SignatureInvalid) return new Response("bad signature", { status: 400 });
if (err instanceof TimestampTooOld) return new Response("timestamp out of window", { status: 400 });
if (err instanceof MalformedHeader) return new Response("malformed headers", { status: 400 });
throw err;
}
}That's the whole thing. The rest of the inbound section explains the parts you'll eventually need to reach for.
What verify returns
On success:
{
event: { type: string; data: unknown; timestamp?: string; /* ... */ },
matchedVerifierIndex: number, // which verifier in the configured array matched (0 if a single Verifier)
}You generally only inspect matchedVerifierIndex during key rotation to detect when a producer is still signing with a deprecated key.
Validating the payload
Attach a Standard Schema — a zod (≥3.24), valibot, or arktype schema — as a source's schema to validate event.data and type it end to end. @postel/core takes no dependency on the schema library; it speaks the Standard Schema interface, so you bring your own.
import { Postel, Secret } from "@postel/core";
import { z } from "zod";
export const postel = Postel({
inbound: {
orders: {
verify: Secret(config.webhookSecret),
schema: z.object({ id: z.string(), total: z.number() }),
},
},
});
const { event } = await postel.inbound.orders.verify(body, headers);
event.data; // typed { id: string; total: number } — inferred from the schemaValidation runs after the signature check. If the payload doesn't match, verify throws EventValidation (code EVENT_VALIDATION), which the framework gate maps to HTTP 422 — distinct from the 400 signature failures (only UnknownKeyId maps to 401; the full mapping is in the errors reference). The inferred type flows through the framework adapters too, so a gated handler's c.var.postel / req.postel carries it. Sources without a schema are unchanged; event.data stays unknown.
What verify throws
Every failure is a typed subclass of PostelError:
| Error class | Code | Meaning |
|---|---|---|
MalformedHeader | MALFORMED_HEADER | A required header is missing or doesn't parse. |
TimestampTooOld | TIMESTAMP_TOO_OLD | The webhook-timestamp is outside the tolerance window (default ±5 minutes). |
SignatureInvalid | SIGNATURE_INVALID | No configured verifier matched the signature. Most common cause: re-serialized bytes (Raw bytes) or a wrong secret. |
UnknownKeyId | UNKNOWN_KEY_ID | JWKS mode only: the kid in the request is not in the cached keyset. |
EventValidation | EVENT_VALIDATION | The source declares a schema and the verified event.data failed it. Thrown after the signature check; the gate maps it to 422. |
Each carries a stable code string for log filters and cross-port discrimination. Error messages are safe to log — they never contain the secret or the signature bytes.
Mistakes in your own configuration — an empty verifier array, a dedup() call without a ttl, a secretOrKeyset that isn't a string, string array, or Keyset — throw ConfigurationError instead, which is deliberately not a PostelError: the framework gate never maps it to a 4xx, so your bug surfaces as a 500 in development instead of rejecting the producer's requests as client errors. See Errors.
Configuration shape
Postel({
inbound: {
<source-name>: {
verify: Verifier | ReadonlyArray<Verifier> // required — Secret/PublicKey/Keyset/Noop, or your own
| VerifierMap, // named map — see /docs/inbound/key-rotation
schema?: StandardSchemaV1, // zod / valibot / … — validates & types event.data
dedup?: DedupAdapter, // see /docs/inbound/deduplication
dedupTtl?: number | string, // "24h", "1d", or seconds
tolerance?: number | string, // "5m" or seconds; default 300
clock?: Clock, // { now(), sleep() } — for tests / deterministic builds
onSuccess?: (event, result) => void, // observability hook
onFailure?: (error, headers) => void, // observability hook
},
// ... more sources
},
})Each source is independently typed. postel.inbound.<source>.dedup only appears on the instance type if dedup is configured — call it on a source without a configured adapter and TypeScript refuses to compile.
What's next
- Provider verifiers — Stripe, GitHub, Shopify, Twilio, Slack, ready-made.
- Custom verifiers — the open
Verifiercontract, andNoop()for trusted boundaries. - Raw bytes — read this before you ship.
- Signing schemes — under the hood, in five steps.
- Key rotation — rotating without dropped requests.
- Deduplication — handling at-least-once.