Inbound

Verify a signed request

The basic receiver recipe — configure once at module scope, call verify() from your handler.

View as Markdown

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

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

export const postel = Postel({
  inbound: {
    vendor: {
      verify: Secret(config.vendorWebhookSecret),
    },
  },
});
app/api/webhooks/vendor/route.ts
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.

lib/postel.ts
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 schema

Validation 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 classCodeMeaning
MalformedHeaderMALFORMED_HEADERA required header is missing or doesn't parse.
TimestampTooOldTIMESTAMP_TOO_OLDThe webhook-timestamp is outside the tolerance window (default ±5 minutes).
SignatureInvalidSIGNATURE_INVALIDNo configured verifier matched the signature. Most common cause: re-serialized bytes (Raw bytes) or a wrong secret.
UnknownKeyIdUNKNOWN_KEY_IDJWKS mode only: the kid in the request is not in the cached keyset.
EventValidationEVENT_VALIDATIONThe 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

On this page