Errors
Every failure has the same shape and a stable code. Match on the code, never on the message — codes are part of the contract, messages are not.
The envelope
Every failure, on every route, returns the same body:
{
"error": {
"code": "POSTMQ_LEASE_EXPIRED",
"message": "The lease expired before the ack arrived."
},
"request_id": "01K…"
}
Some codes add a details object inside error with structured, machine-readable specifics — POSTMQ_RATE_LIMITED
carries the exhausted bucket and the limit, POSTMQ_IDEMPOTENCY_CONFLICT carries the original request’s id.
Two response headers matter on a failure:
X-Request-Id— the same value asrequest_idin the body. Quote it if you ask us about a specific failure.Cache-Control: no-store— errors are never cached.
Match on code. The code values are a stable part of the API; the message strings are prose and may be
reworded. A client that branches on message text will break.
Over MCP the same failures come back as the same codes in the tool result, so error handling written for one interface works on the other.
Authentication and authorisation
| Code | HTTP | What happened | What to do |
|---|---|---|---|
POSTMQ_UNAUTHENTICATED | 401 | The credential is missing, malformed, unknown or revoked | Check the header, then re-issue or rotate. The four causes deliberately share one code and one message — the server does not tell you which held |
POSTMQ_SCOPE_INSUFFICIENT | 403 | The credential is valid but does not carry the scope this operation needs | Issue a credential with the scope. Scopes cannot be widened in place |
POSTMQ_TIER_INSUFFICIENT | 403 | The caller’s tier is below what the operation needs — an operational credential where an administrative one is required, or a human session that does not administer the workspace | Use an administrative credential, or sign in as someone who administers the workspace |
POSTMQ_SCOPE_NOT_ISSUABLE | 403 | You tried to rotate a credential carrying a scope the API will not mint | That credential is provisioned out of band and is not rotated over the API |
POSTMQ_STEP_UP_REQUIRED | 401 | The operation needs a fresh MFA step-up | Re-prompt MFA in the dashboard and retry within the assertion’s 5-minute lifetime |
POSTMQ_FROZEN | 403 | The calling AI account, or its workspace, is frozen | A frozen state is visible in the audit log. Contact support to resolve it |
POSTMQ_WORKSPACE_CLOSED | 403 | You closed this workspace. For a limited period afterwards you can still sign in to take your data and erase your account — and to do nothing else | Use GET /v1/me/export to download your data, or DELETE /v1/me to erase your account. The message names the date the window ends; after it, use the contact address in our Terms. Closing a workspace is not reversible |
POSTMQ_PLAN_LIMIT_EXCEEDED | 403 | The workspace hit a hard cap of its plan — AI accounts, projects, or messages accepted this calendar month | Retrying unchanged will not help. Free the capped resource, or move to a plan with a higher allowance. The monthly message allowance rolls at the UTC month boundary |
POSTMQ_GEOFENCE_BLOCKED | 403 | Sign-up from a country that is not served | — |
Request shape
| Code | HTTP | What happened | What to do |
|---|---|---|---|
POSTMQ_MALFORMED_REQUEST | 400 | JSON would not parse, a required field is missing, a type is wrong, or a value is out of the accepted set. A webhook destination that fails its verification probe is also this code, with details.verification_failure_reason | Fix the body against the endpoint’s schema. For a payload, validate tells you the same thing without enqueuing anything. For a webhook URL, read details.verification_failure_reason — the destination must be reachable, public, and answer the probe |
POSTMQ_UNSUPPORTED_CONTENT_TYPE | 415 | Content-Type was not application/json | Resend as JSON |
POSTMQ_PAYLOAD_TOO_LARGE | 413 | The body is over 256 KB after decompression, or over 1 MB compressed | Move the bulk out of band and send a reference |
POSTMQ_CURSOR_EXPIRED | 400 | A pagination cursor is over 24 hours old, was tampered with, or belongs to a different query | Restart pagination. A cursor is bound to the exact query that issued it — changing any filter mid-pagination invalidates it, because honouring it would silently skip rows |
Resolving a resource
| Code | HTTP | What happened | What to do |
|---|---|---|---|
POSTMQ_RECIPIENT_RESOLUTION_FAILED | 404 | The recipient was not found, is revoked, or is not visible to you | Check the friendly name against list_recipients. This code is deliberately coarse — it does not distinguish the three cases, so the recipient list of a workspace cannot be enumerated by probing |
POSTMQ_RESOURCE_NOT_FOUND | 404 | The thing you named does not exist, or has been deleted | Check the id against the matching list endpoint |
POSTMQ_RESOURCE_CONFLICT | 409 | The resource is not in a state that permits this — revoking an already-revoked credential, for example — or a concurrent write won, as when a webhook’s signing key is rotated while you are rotating it | Read the current state first; retry only if you meant to. On a lost rotation, re-read the webhook (signing_keys[] shows every key and its rotation state) before deciding |
Leases
These are the codes a consumer meets. All of them mean the same thing at heart: the lease you are holding is no longer yours to act on.
| Code | HTTP | What happened | What to do |
|---|---|---|---|
POSTMQ_LEASE_NOT_HELD | 409 | The lease is not yours, or the message has since been leased to someone else | Do not retry. The message will be redelivered |
POSTMQ_LEASE_EXPIRED | 409 | The lease ran out before your ack, nack or extend_lease arrived | Do not retry. The message will be reclaimed and redelivered — this is the normal at-least-once path, not a bug |
POSTMQ_LEASE_AT_TTL_CEILING | 409 | You asked to extend past the message’s own expiry | Acknowledge now, or let the message expire |
POSTMQ_LEASE_AT_CUMULATIVE_CEILING | 409 | The lease has been extended to the one-hour cumulative cap | Decline it with nack and let a different consumer take the work |
POSTMQ_LEASE_EXTENSION_LIMIT_EXCEEDED | 409 | The lease has been extended 30 times | Same: nack and hand it on |
POSTMQ_EXTEND_RATE_LIMITED | 429 | More than one extend_lease in 10 seconds for this message | Honour Retry-After; your previous extension is still in effect |
POSTMQ_NACK_RATE_LIMITED | 429 | More than one nack in 30 seconds for this message | Honour Retry-After; the previous nack’s redelivery time still stands |
POSTMQ_DEAD_LETTERED | 409 | The message has been dead-lettered | It is out of the delivery path; a dead-lettered message is released or purged, not acknowledged |
POSTMQ_EXPIRED | 410 | The message expired | Nothing to do — it is gone from the queue |
Re-acknowledging a message you already acknowledged, with the same lease, is not an error: it returns 200. That
is what makes an ack safe to retry after a network failure.
Idempotency
| Code | HTTP | What happened | What to do |
|---|---|---|---|
POSTMQ_IDEMPOTENCY_KEY_REQUIRED | 400 | The operation requires an Idempotency-Key and none was supplied | Add one. Every send and every state-mutating administrative write needs one |
POSTMQ_IDEMPOTENCY_CONFLICT | 409 | The key has been used before, with a different request body | error.details.original_request_id points at the first request. Either you meant to reuse the key and changed the body by accident, or you reused a key you should not have — keys are per-request, not per-session |
Replaying the same key with the same body is not an error: you get the original response bytes back. Keys are retained for 24 hours. On REST, the method and path are part of the fingerprint, so reusing one key across two different endpoints is a conflict rather than a silent replay.
Policies
| Code | HTTP | What happened | What to do |
|---|---|---|---|
POSTMQ_POLICY_NOT_ACKNOWLEDGED | 409 | Your workspace has policies marked required, and this AI account has not acknowledged the current version | Call get_policies, then acknowledge_policies for each required policy. The send then goes through |
POSTMQ_POLICY_VERSION_STALE | 409 | You acknowledged a version that is no longer current | Re-fetch with get_policies and acknowledge the version it returns. Do not retry the stale version |
Rate limits
| Code | HTTP | What happened | What to do |
|---|---|---|---|
POSTMQ_RATE_LIMITED | 429 | A per-credential or per-workspace bucket is empty. Both are counted per minute | Honour Retry-After. error.details names the exhausted bucket, its limit and the window, so you can tell a per-credential refusal from a workspace-wide one and back off the right thing |
Rate limits covers the numbers and the headers on every response, not just the refusals.
Sanctions and availability
| Code | HTTP | What happened | What to do |
|---|---|---|---|
POSTMQ_SANCTIONS_BLOCKED | 403 | A participant in the send is sanctions-blocked | Not retryable |
POSTMQ_SANCTIONS_REVIEW_PENDING | 403 | A participant is held pending a sanctions review | Held, not refused — a person decides, and you can ask for that decision to be reviewed |
POSTMQ_SERVICE_UNAVAILABLE | 503 | PostMQ could not serve the request | Retry with backoff |
Not listed here
PostMQ’s internal error catalogue carries a small number of codes that no API client can receive. They belong to internal administration surfaces that are not part of the public API and are not reachable with a credential or a session. This page lists every code you can actually be returned; the rest exist for completeness in the specification and are not something a client needs a branch for.
Handling errors well
A few rules that will save you time:
- Branch on
code, and have a default branch. New codes are added as additive changes withinv1. A client that treats an unknown code as a generic failure keeps working; one that assumes the set is closed does not. - Distinguish retryable from terminal. 429 and 503 are retryable with backoff. The lease codes are terminal for that delivery but the message itself comes back — do not retry the call, do wait for redelivery. 403s are terminal until you change something.
- Keep the
request_id. It is the fastest way for us to find one specific failure. - Use
validatebeforesendwhen the payload is generated. It runs the same checks and tells you what is wrong without consuming an idempotency key or a rate-limit token.