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/inbound/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: 401 });
if (err instanceof TimestampTooOld) return new Response("timestamp out of window", { status: 401 });
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/401 signature failures. 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. |
RawBytesMismatchDetected | RAW_BYTES_MISMATCH_DETECTED | Framework adapter detected the body was mutated before verify was called. |
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
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.
Custom verifiers
Verifier is an open contract, not a fixed set — Secret, PublicKey, and Keyset are just the built-ins. Anything implementing the contract works in a verify slot, so you can plug a vendor whose scheme isn't Standard Webhooks, a shared-token check, or a call out to a verification service:
import { Postel, SignatureInvalid, type Verifier } from "@postel/core";
import { config } from "./config.js";
function PartnerToken(expected: string): Verifier {
return {
async verify(rawBody, headers) {
if (headers["x-partner-token"] !== expected) {
throw new SignatureInvalid("partner token mismatch");
}
const text = typeof rawBody === "string" ? rawBody : new TextDecoder().decode(rawBody);
return { event: JSON.parse(text), matchedSecretIndex: 0 };
},
};
}
export const postel = Postel({
inbound: {
partner: { verify: PartnerToken(config.partnerToken) },
},
});A verifier returns a VerifyResult ({ event, matchedSecretIndex }) on success and throws on failure. Throw a PostelError subclass — SignatureInvalid, MalformedHeader, … — so the framework gate maps it to the right HTTP status; any other error bubbles as a 5xx. Custom verifiers compose in arrays alongside the built-ins exactly like Secret/Keyset do — tried in order, first match wins, and matchedVerifierIndex reports which one matched. Two errors short-circuit the array instead of falling through to the next verifier: TimestampTooOld (timestamp validity is independent of which key signed) and ConfigurationError (a config bug is not evidence about the signature — it rethrows immediately, never folded into SignatureInvalid):
verify: [Secret(config.legacySecret), PartnerToken(config.partnerToken)]Skipping verification with Noop()
If your receiver sits behind a trusted boundary — a private network, or a gateway that already authenticated the caller — and you accept the trade-off, Noop() skips the signature check, the timestamp window, and the signing-header requirement entirely:
import { Postel, Noop } from "@postel/core";
export const postel = Postel({
inbound: {
internal: { verify: Noop() },
},
});Noop() still parses the Standard Webhooks envelope, so event.type, event.data, and a source schema behave exactly as they do for a verified source — a body that isn't a JSON object with a string type is still rejected. What it does not do is authenticate the sender.
Security.
Noop()accepts unauthenticated requests: anyone who can reach the endpoint can deliver an event. Reach for it only when something in front of the receiver already establishes trust.
What's next
- 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.
- Web adapters — wire verification into Hono, Express, Fastify, NestJS, or any runtime.