Errors
Every PostelError subclass — code, meaning, when it's thrown, how to recover.
@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.workersset to a non-in-process strategy —BullMQ(...),PgBoss(...), orExternal(...)outbound.kmsset to anything butPlaintextKms—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)maxInflightonendpoints.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:
| 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:
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.