Errors

Every PostelError subclass — code, meaning, when it's thrown, how to recover.

View as Markdown

@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

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

MalformedHeader

Code: MALFORMED_HEADER Thrown by: postel.inbound.<source>.verify — step 1. 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.

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

TimestampTooOld

Code: TIMESTAMP_TOO_OLD Thrown by: postel.inbound.<source>.verify — step 2. 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.

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

SignatureInvalid

Code: SIGNATURE_INVALID Thrown by: postel.inbound.<source>.verify — step 4. 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 — check whether your framework adapter (or a proxy in front of it) re-serializes JSON before verify sees it.

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

UnknownKeyId

Code: UNKNOWN_KEY_ID Thrown by: postel.inbound.<source>.verify — step 3 (JWKS mode only). 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.

EventValidation

Code: EVENT_VALIDATION 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. When: The event's data fails the configured 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.

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

EndpointValidation

Code: ENDPOINT_VALIDATION Thrown by: postel.outbound.endpoints.create(...) / endpoints.update(...) (outbound). 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).

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

SsrfBlocked

Code: SSRF_BLOCKED Raised by: the dispatcher, at delivery time (outbound). 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

Code: ENDPOINT_NOT_FOUND 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). 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.

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

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 agree on the vocabulary.

ConfigurationError

Code: CONFIGURATION_ERROR Thrown by: Any API you call with a broken configuration. 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

Code: NOT_IMPLEMENTED 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 PlaintextKmsAwsKms(...), 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

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

CodeStatus
SIGNATURE_INVALID, TIMESTAMP_TOO_OLD, MALFORMED_HEADER, ENDPOINT_DISABLED, SSRF_BLOCKED400
UNKNOWN_KEY_ID401
EVENT_VALIDATION, ENDPOINT_VALIDATION422
ENDPOINT_NOT_FOUND404
MIGRATION_REQUIRED500

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

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 — every port (Go, Python, Rust, …) must emit the same codes for the same conditions. The class hierarchy is TypeScript-specific.

On this page