# Verify a signed request



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 [#the-recipe]

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

export const postel = Postel({
  inbound: {
    vendor: {
      verify: Secret(config.vendorWebhookSecret),
    },
  },
});
```

```ts title="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 [#what-verify-returns]

On success:

```ts nocheck
{
  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](/docs/inbound/key-rotation) to detect when a producer is still signing with a deprecated key.

## Validating the payload [#validating-the-payload]

Attach a [Standard Schema](https://github.com/standard-schema/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.

```ts title="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](/docs/reference/errors#http-status-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 [#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](/docs/concepts/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](/docs/reference/errors).

## Configuration shape [#configuration-shape]

```ts nocheck
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 [#whats-next]

* [Provider verifiers](/docs/inbound/providers) — Stripe, GitHub, Shopify, Twilio, Slack, ready-made.
* [Custom verifiers](/docs/inbound/custom-verifiers) — the open `Verifier` contract, and `Noop()` for trusted boundaries.
* [Raw bytes](/docs/concepts/raw-bytes) — read this before you ship.
* [Signing schemes](/docs/inbound/signing) — under the hood, in five steps.
* [Key rotation](/docs/inbound/key-rotation) — rotating without dropped requests.
* [Deduplication](/docs/inbound/deduplication) — handling at-least-once.
