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)
├── RawBytesMismatchDetected code: "RAW_BYTES_MISMATCH_DETECTED" (inbound)
├── EventValidation code: "EVENT_VALIDATION" (inbound + outbound)
├── EndpointValidation code: "ENDPOINT_VALIDATION" (outbound)
├── SsrfBlocked code: "SSRF_BLOCKED" (outbound)
├── IdempotencyKeyConflict code: "IDEMPOTENCY_KEY_CONFLICT" (reserved)
├── EndpointDisabled code: "ENDPOINT_DISABLED" (reserved)
└── MigrationRequired code: "MIGRATION_REQUIRED" (reserved)
Error
├── ConfigurationError code: "CONFIGURATION_ERROR"
└── NotImplementedError code: "NOT_IMPLEMENTED"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. 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: 401 Unauthorized. 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: 401 });
}SignatureInvalid
Code: SIGNATURE_INVALID
Thrown by: postel.inbound.<source>.verify — step 4 / step 5.
When: No configured verifier matched the request's signature. Also thrown if the body fails to JSON-parse after a successful signature check (which almost always means downstream tampering).
Recovery: 401 Unauthorized. The single most common cause is body re-serialization. Check RawBytesMismatchDetected first; if that didn't fire, you have a real secret mismatch or a different signing scheme.
if (err instanceof SignatureInvalid) {
return new Response("bad signature", { status: 401 });
}UnknownKeyId
Code: UNKNOWN_KEY_ID
Thrown by: postel.inbound.<source>.verify — step 3 (JWKS mode only).
When: The kid in the request's webhook-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.
RawBytesMismatchDetected
Code: RAW_BYTES_MISMATCH_DETECTED
Thrown by: Framework adapters (when they implement detection).
When: The body bytes look like they were JSON-re-encoded between receipt and verify — whitespace patterns, key ordering, number formatting all suggest mutation.
Recovery: Best-effort safety net. Fix the underlying re-serialization (don't catch and ignore). See Raw bytes.
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.
Reserved codes (defined, not yet emitted by the runtime)
IdempotencyKeyConflict (IDEMPOTENCY_KEY_CONFLICT), 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 yet: idempotent send reuses the existing message id rather than conflicting, a disabled endpoint is recorded as a skipped attempt (not a thrown error), and the in-memory storage adapter has no migration step. They become live when strict-idempotency conflict reporting and the database-backed storage adapters land.
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 worker strategy that has no runtime yet.
When: You set outbound.workers to a non-in-process strategy — BullMQ(...), PgBoss(...), or External(...). The outbound runtime itself is available; send, replay, endpoints.*, keys.*, tenants.*, and messages.* all work against the in-process InMemoryStorage adapter. Only the external job-queue strategies are config slots without an adapter today.
Recovery: Use the in-process worker pool (InProcess(...), the default) until the BullMQ / PgBoss adapters ship. Failing fast here is deliberate — it stops a queue-backed config from silently running in-process.
Discriminating by code
If you're not on TypeScript, or you're filtering logs:
import { PostelError } from "@postel/core";
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: err.code === "MALFORMED_HEADER" ? 400 : 401 });
}
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.