Inbound

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

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/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.

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/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 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.
RawBytesMismatchDetectedRAW_BYTES_MISMATCH_DETECTEDFramework adapter detected the body was mutated before verify was called.
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
      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:

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

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

On this page