# Errors



`@postel/core` never returns `false` for a failed verification. Every failure is a typed `PostelError` subclass with a stable SCREAMING\_SNAKE `code`. The `code` is the cross-port discriminator; the class identity (`instanceof X`) is the TypeScript-side ergonomic.

Error messages are safe to log — they never contain the secret or the signature bytes.

## The hierarchy [#the-hierarchy]

```
Error
└── PostelError                      (abstract)
    ├── MalformedHeader              code: "MALFORMED_HEADER"            (inbound)
    ├── TimestampTooOld              code: "TIMESTAMP_TOO_OLD"           (inbound)
    ├── SignatureInvalid             code: "SIGNATURE_INVALID"           (inbound)
    ├── UnknownKeyId                 code: "UNKNOWN_KEY_ID"              (inbound)
    ├── EventValidation              code: "EVENT_VALIDATION"            (inbound + outbound)
    ├── EndpointValidation           code: "ENDPOINT_VALIDATION"         (outbound)
    ├── SsrfBlocked                  code: "SSRF_BLOCKED"                (outbound)
    ├── EndpointNotFound             code: "ENDPOINT_NOT_FOUND"          (outbound)
    ├── EndpointDisabled             code: "ENDPOINT_DISABLED"           (reserved)
    └── MigrationRequired            code: "MIGRATION_REQUIRED"          (reserved)

Error
├── ConfigurationError               code: "CONFIGURATION_ERROR"
└── NotImplementedError              code: "NOT_IMPLEMENTED"
```

Every code is a member of the exported `PostelErrorCode` union (`"SIGNATURE_INVALID" | "TIMESTAMP_TOO_OLD" | …`) — the cross-port discriminator type.

When an inbound source composes several verifiers and none match, the thrown error additionally carries `errors: ReadonlyArray<VerifierFailure>` — one `{ verifierIndex, error }` entry per rejecting verifier, in configuration order — and its `cause` is an `AggregateError` over the same failures. "Which verifier rejected and why" is a property read, not archaeology.

`ConfigurationError` and `NotImplementedError` are **not** `PostelError` subclasses on purpose — they signal developer mistakes (a misconfigured source, a not-yet-implemented config slot), not verification outcomes. The `if (err instanceof PostelError) return 4xx` pattern deliberately misses them, so a config bug in your integration bubbles as a 500/crash instead of masquerading as a client error.

## Class reference [#class-reference]

### `MalformedHeader` [#malformedheader]

**Code:** `MALFORMED_HEADER`
&#x2A;*Thrown by:** `postel.inbound.<source>.verify` — step 1.
&#x2A;*When:** A required webhook header (`webhook-id`, `webhook-timestamp`, `webhook-signature`) is missing or doesn't parse — or the body, after a successful signature check, isn't a JSON object with a string `type`. Strictly a wire-format outcome — mistakes in your own configuration (an empty verifier array, a bad `secretOrKeyset`) throw `ConfigurationError` instead.

**Recovery:** `400 Bad Request`. The producer's request is malformed; retrying won't help.

```ts
if (err instanceof MalformedHeader) {
  return new Response("malformed headers", { status: 400 });
}
```

### `TimestampTooOld` [#timestamptooold]

**Code:** `TIMESTAMP_TOO_OLD`
&#x2A;*Thrown by:** `postel.inbound.<source>.verify` — step 2.
&#x2A;*When:** `|now - webhook-timestamp| > toleranceSeconds`. Default tolerance is 300 seconds (±5 minutes). Short-circuits the verifier array — timestamp validity is independent of which key signed.

**Recovery:** `400 Bad Request` — the status Postel's own framework gates return. Most often clock skew on the producer side or a replay attempt. Retrying won't help unless the producer fixes their clock.

```ts
if (err instanceof TimestampTooOld) {
  return new Response("timestamp out of window", { status: 400 });
}
```

### `SignatureInvalid` [#signatureinvalid]

**Code:** `SIGNATURE_INVALID`
&#x2A;*Thrown by:** `postel.inbound.<source>.verify` — step 4.
&#x2A;*When:** No configured verifier matched the request's signature. (When every verifier fails for the same wire-format reason, the composed error is `MalformedHeader` instead — mixed failure reasons surface as `SignatureInvalid`.)

**Recovery:** `400 Bad Request` — the status Postel's own framework gates return. The single most common cause is [body re-serialization](/docs/concepts/raw-bytes) — check whether your framework adapter (or a proxy in front of it) re-serializes JSON before `verify` sees it.

```ts
if (err instanceof SignatureInvalid) {
  return new Response("bad signature", { status: 400 });
}
```

### `UnknownKeyId` [#unknownkeyid]

**Code:** `UNKNOWN_KEY_ID`
&#x2A;*Thrown by:** `postel.inbound.<source>.verify` — step 3 (JWKS mode only).
&#x2A;*When:** The `kid` in the request's `webhook-key-id` header doesn't appear in the cached keyset. The next verifier in the array is tried; if every verifier exhausts, the final error is `SignatureInvalid`.

**Recovery:** Usually transient — the producer rotated keys faster than your keyset cache TTL. Check that your `refreshEvery` and `cacheTtl` settings match the producer's rotation cadence. See [Key rotation → JWKS](/docs/inbound/key-rotation#jwks--asymmetric-rotation-without-secret-sharing).

### `EventValidation` [#eventvalidation]

**Code:** `EVENT_VALIDATION`
&#x2A;*Thrown by:** `postel.inbound.<source>.verify` — after the signature check, when the source declares a `schema`. Also thrown by `postel.outbound.send` when the call site's `type` is registered in `outbound.events` and `data` fails that schema.
&#x2A;*When:** The event's `data` fails the configured [Standard Schema](https://github.com/standard-schema/standard-schema) (zod / valibot / arktype). Carries the schema's `issues` (`err.issues`).

**Recovery:** Inbound: `422 Unprocessable Entity` — the framework gate maps `EVENT_VALIDATION` there automatically. The signature was valid but the payload shape was not, so retrying the same body won't help. Outbound: the `send()` call rejects and no outbox row is written — fix the `data` shape and call again.

```ts
if (err instanceof EventValidation) {
  return new Response(JSON.stringify({ issues: err.issues }), { status: 422 });
}
```

### `EndpointValidation` [#endpointvalidation]

**Code:** `ENDPOINT_VALIDATION`
&#x2A;*Thrown by:** `postel.outbound.endpoints.create(...)` / `endpoints.update(...)` (outbound).
&#x2A;*When:** The endpoint URL is rejected at create/update time — unparseable, not `http(s)`, plain `http://` without `allowHttp`, resolves to an SSRF-eligible (private / loopback / link-local) address, or does not resolve at all (DNS failure). Validation runs before the endpoint is persisted.

**Recovery:** `4xx` from your admin surface. The URL or `allowHttp` flag is wrong; fix it and retry. This is the create-time half of SSRF defense (the dispatch-time half is `SsrfBlocked`, below).

```ts
if (err instanceof EndpointValidation) {
  return new Response(err.message, { status: 422 });
}
```

### `SsrfBlocked` [#ssrfblocked]

**Code:** `SSRF_BLOCKED`
&#x2A;*Raised by:** the dispatcher, at delivery time (outbound).
&#x2A;*When:** An endpoint URL that passed create-time validation resolves into a blocked range *at dispatch* (e.g. DNS rebinding, or a record that changed since creation). The dispatcher catches it internally and records the attempt with status `ssrf-blocked` — it is **not** thrown back to the `send()` caller (delivery happens asynchronously in a worker). You observe it on the attempt, not in a `try/catch` around `send()`.

**Recovery:** The target now resolves to a private address; the delivery is held (treated as a retryable failure) rather than sent. Point the endpoint at a public address.

### `EndpointNotFound` [#endpointnotfound]

**Code:** `ENDPOINT_NOT_FOUND`
&#x2A;*Thrown by:** `postel.outbound.endpoints.get(id)`; `postel.outbound.endpoints.update(id, ...)` when the patch touches `url`, `allowHttp`, or `http` (those fields re-validate against the endpoint's current record before applying).
&#x2A;*When:** No endpoint exists with the given `id`.

**Recovery:** `404 Not Found` from your admin surface. The id is wrong or the endpoint was deleted; there's nothing to retry.

```ts
if (err instanceof EndpointNotFound) {
  return new Response(err.message, { status: 404 });
}
```

### Reserved codes (defined, not yet emitted by the runtime) [#reserved-codes-defined-not-yet-emitted-by-the-runtime]

`EndpointDisabled` (`ENDPOINT_DISABLED`) and `MigrationRequired` (`MIGRATION_REQUIRED`) are part of the cross-port error taxonomy and ship as `PostelError` subclasses you can reference, but the TypeScript runtime does not throw them today: a disabled endpoint is recorded as a `skipped` attempt (not a thrown error), and the storage adapters gate schema drift through their own migration checks. The codes stay reserved so ports and the [admin surface](/docs/operations/admin) agree on the vocabulary.

### `ConfigurationError` [#configurationerror]

**Code:** `CONFIGURATION_ERROR`
&#x2A;*Thrown by:** Any API you call with a broken configuration.
&#x2A;*When:** You made an integration mistake, not the webhook producer: an inbound source with no verifiers configured, `dedup()` called without a ttl (in config or at the call site), an unparsable ttl value, an empty secret array, a `secretOrKeyset` that is not a string / string array / Keyset, a receiver-side secret carrying the `whsk_` private-key prefix, `createKeyset` in a runtime without `fetch`, or `signFixture` with a non-HMAC secret.

**Recovery:** Fix your code. It is not a `PostelError` — the framework gate and `@postel/admin` never map it to a 4xx, so it surfaces as a 500/crash in development instead of silently rejecting the producer's requests. When a verifier in a composed array throws it, the composition loop rethrows immediately rather than folding it into `SignatureInvalid`.

### `NotImplementedError` [#notimplementederror]

**Code:** `NOT_IMPLEMENTED`
&#x2A;*Thrown by:** Configuring a typed-but-unshipped config slot. All of these fail fast at construction (or at `endpoints.create`/`update`) rather than accepting a value the runtime would silently ignore:

* `outbound.workers` set to a non-in-process strategy — `BullMQ(...)`, `PgBoss(...)`, or `External(...)`
* `outbound.kms` set to anything but `PlaintextKms` — `AwsKms(...)`, `GcpKms(...)`, `Vault(...)`
* `outbound.retention` (automatic pruning has not shipped)
* `outbound.ephemeralKeys` (timer-driven key rotation has not shipped)
* `outbound.http.tls` / `outbound.http.dns` (TLS opt-out and DNS pinning are not wired)
* `maxInflight` on `endpoints.create(...)` / `endpoints.update(...)` (per-endpoint concurrency caps are not wired)

**Recovery:** Remove the unwired slot (or use the shipped default, e.g. the in-process worker pool). Failing fast here is deliberate — it stops an unwired config from silently no-opping.

## HTTP status mapping [#http-status-mapping]

Every framework gate (and `@postel/http`'s `statusForError`) maps codes to statuses the same way:

| Code                                                                                              | Status |
| ------------------------------------------------------------------------------------------------- | ------ |
| `SIGNATURE_INVALID`, `TIMESTAMP_TOO_OLD`, `MALFORMED_HEADER`, `ENDPOINT_DISABLED`, `SSRF_BLOCKED` | `400`  |
| `UNKNOWN_KEY_ID`                                                                                  | `401`  |
| `EVENT_VALIDATION`, `ENDPOINT_VALIDATION`                                                         | `422`  |
| `ENDPOINT_NOT_FOUND`                                                                              | `404`  |
| `MIGRATION_REQUIRED`                                                                              | `500`  |

Rolling your own handler? Use the same mapping so producers see one consistent contract:

```ts
import { PostelError } from "@postel/core";
import { statusForError } from "@postel/http";

try {
  await postel.inbound.vendor.verify(body, headers);
} catch (err) {
  if (err instanceof PostelError) {
    log.warn("verify failed", { code: err.code, message: err.message });
    return new Response("rejected", { status: statusForError(err) });
  }
  throw err;
}
```

The `code` string is part of the [CONTRACT](/docs/project/specs) — every port (Go, Python, Rust, …) must emit the same codes for the same conditions. The class hierarchy is TypeScript-specific.
