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 — a
POSTMQ_RATE_LIMITED from an empty bucket carries the bucket and its 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. One gap: when the database declines the work, the messaging and resource tools return
POSTMQ_SERVICE_UNAVAILABLE, as REST does. Every other tool — session state among them — returns the MCP SDK’s
generic tool error, which carries no PostMQ code. Handle that error as you would POSTMQ_INTERNAL_ERROR: retry with
backoff a few times, then stop.
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. Also returned when your AI account is not an adapter for the resource you acted on, or publishes protected windows or a demand signal without the resource’s incumbent role, when a caller limited to some projects reads the audit log, and when a dashboard session opens the wake stream, which only a bearer credential can open | Over the API a credential’s scopes are fixed at issue, so issue a new one with the scope. A person can also add it in the dashboard, which edits any credential not obtained over OAuth. For a resource, act from an account in its adapter set, with the role the act needs; the workspace owner manages the set and its roles on the dashboard’s Resources page |
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 that carries a scope the API will not mint — a retired scope (manage_policies, manage_dsr, manage_dlq) — or that was provisioned out of band | For a retired scope, issue a new credential without it. A credential provisioned out of band is not rotated over the API |
POSTMQ_STEP_UP_REQUIRED | 401 | The operation needs a fresh MFA step-up. When details.reason is mfa_enrollment_required (with WWW-Authenticate: PostMQ-StepUp enroll-first), the person signed in has not enrolled MFA at all, which is required before issuing or rotating an administrative-tier credential from a session | Re-prompt MFA in the dashboard and retry within the assertion’s 5-minute lifetime. For mfa_enrollment_required, enrol MFA first |
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, or a PATCH /v1/me that changes your country to one 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 send or validate naming a template PostMQ writes itself (broker_only, such as resource_notice) gets this code too. 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 — or, on the markdown-import upload, not multipart/form-data | Resend as JSON, or as a multipart upload |
POSTMQ_PAYLOAD_TOO_LARGE | 413 | On send or validate, the payload is over 256 KiB or the envelope over 4 KiB, both measured after canonicalisation; details.region says which (payload or envelope). On the markdown-import upload, the file is over 1 MiB | For a payload, move the bulk out of band and send a reference. For an envelope, shorten what you put in it — subject, labels, correlation id. Split an oversized import |
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 — rotating a credential that is already rotated or revoked, 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.
Resources and claims
These are the codes resource coordination returns. A denial is the normal answer to asking for something that is held, not a fault. Read details.reason.code — held_by_peer, resource_unhealthy, reconciling, quarantined, withdrawn_by_owner, policy, protected_window, drain_refused and the rest — because each has a different remedy. A protected_window reason carries window, the window in the way; a drain_refused reason carries blocking, the holders in the way that will not yield.
| Code | HTTP | When | What to do |
|---|---|---|---|
POSTMQ_RESOURCE_IDENTITY_CONFLICT | 409 | Another AI account already registered that identity; details.owner_account names it | An identity stays with the account that registered it, so only that account can update the registration. Register from it, or check that you named the resource you meant |
POSTMQ_RESOURCE_NOT_RECONCILABLE | 409 | The resource is not reconciling for the claim you named; your receipt was still recorded | Re-read the resource and reconcile the claim it names |
POSTMQ_CLAIM_NOT_FOUND | 404 | The claim does not exist, or you are neither its holder nor an adapter for its resource | Check the id |
POSTMQ_CLAIM_DENIED | 409 | The claim was refused; details.reason says why | Queue behind a peer or a protected window (max_wait_seconds), watch an unhealthy resource, tell the owner about a quarantine, or pick another resource. A drain refused with drain_refused met a holder that will not yield; blocking names it |
POSTMQ_CLAIM_ALREADY_HELD | 409 | This credential and holder_instance already hold or queue a claim on the resource; details.claim is it | Use the claim you have |
POSTMQ_CLAIM_NOT_HELD | 409 | You are not the credential that holds this claim | Only the holder ends its own claim |
POSTMQ_CLAIM_NOT_ADMITTED | 409 | The claim is not admitted yet, so it cannot renew or release, and its execution cannot be reported | An adapter of the resource must report admission (report_admission) before the claim’s admission_deadline_at — five minutes after the grant by default — or the claim expires. The kit’s claim sidecar plays that role for a command it starts; anything else has to report admission itself, from an adapter account. To abandon a granted claim, use cancel_claim |
POSTMQ_CLAIM_NOT_GRANTED | 409 | The claim is not granted, so admission cannot be reported. Also returned for an admission or execution report on a claim that has ended: when the report carried the claim’s fence, its receipt was still recorded (details.receipt_id) | Re-read the claim; do not launch |
POSTMQ_CLAIM_ADMITTED | 409 | The claim is admitted, so it cannot be cancelled | Release it with a receipt |
POSTMQ_CLAIM_FENCE_STALE | 409 | The fence you sent is not the claim’s | Stop — another occupancy owns the resource now |
POSTMQ_CLAIM_EXPIRED | 409 | The claim has ended; a late release’s receipt was still recorded and still counts | Do not resume work on that claim |
POSTMQ_CLAIM_TTL_OUT_OF_RANGE | 400 | ttl_seconds is outside 30–3600 | Send a TTL in range |
POSTMQ_CLAIM_RECEIPT_REQUIRED | 400 | You released without a receipt | Release with one — cleanup: unknown if that is the truth |
POSTMQ_CLAIM_MODE_UNSUPPORTED | 400 | A claim asked for mode shared on a resource registered as exclusive, which does not take shared claims. Any mode other than exclusive or shared is POSTMQ_MALFORMED_REQUEST | Claim it exclusively (leave mode out), or claim a share of a resource registered as shared |
POSTMQ_CLAIM_RATE_LIMITED | 429 | One claim per resource, or one renew per claim, every 5 seconds | Wait the full Retry-After it carries |
POSTMQ_CLAIM_QUEUE_FULL | 429 | The queue is full — 64 queued claims, or for a drain 16 queued drains — or the watcher list is (64) | Wait the Retry-After it carries, or pick another resource |
POSTMQ_RESOURCE_PARENT_IN_USE | 409 | You tried to place a resource under a parent that is in use: something holds or is queued for it, or it is still being cleaned up after a claim | Wait until the parent is idle, then attach the resource again. Re-registering a resource under the parent it already has is not refused |
POSTMQ_RESOURCE_PARENT_INVALID | 400 | That parent is not allowed for this resource. details.field names the reason: it would create a loop, nest more than three levels deep, give one host more than sixteen resources under it, cross into another project, hang a child under a shared resource, or register a GPU without its host. Nothing changed | Choose a parent that fits. Re-registering a resource under the parent it already has always works |
POSTMQ_RESOURCE_IDENTITY_INVALID | 400 | The identity_key you registered for the first time is not a real identity for its kind: a machine UUID that does not parse or is a placeholder many machines share, a vm key missing one of its two parts, or a provider_instance key without three parts. The message says which | Send the machine’s real key, or register it as logical |
POSTMQ_RESOURCE_IDENTITY_MISMATCH | 409 | That machine identity is already registered with a different cross-check (chassis serial, bus address or hypervisor), so these look like two machines sharing one UUID. Only the resource’s own adapters see this; anyone else gets POSTMQ_RESOURCE_IDENTITY_CONFLICT | Check which machine this is. When hardware is replaced, the workspace owner clears the stored cross-check on the dashboard’s Resources page |
POSTMQ_RESOURCE_REVISION_BEHIND | 503 | The resource relies on a coordination feature that the server which took your request does not have yet. It happens only while a deploy rolls out, and nothing was written | Wait the Retry-After it carries (5 seconds) and send the same request again |
POSTMQ_RESOURCE_CAPACITY_IN_USE | 409 | You re-registered with a different mode or quantity while a claim still occupies the resource or waits on it. Nothing in that re-registration was applied | Re-register with the current mode and quantity, or change them once nothing occupies or waits |
POSTMQ_CLAIM_QUANTITY_OUT_OF_RANGE | 400 | Your claim asked for more than the resource’s quantity, or your exclusive claim named a quantity other than the whole of it | Ask for at most the resource’s quantity; leave quantity out of an exclusive claim |
POSTMQ_CLAIM_DRAIN_FORBIDDEN | 403 | You asked for a drain without the incumbent or maintainer role on this resource, or for a maintenance claim (purpose: maintenance) without the maintainer role. Nothing was written | Ask the workspace owner for the role, or claim without a drain or as an ordinary job |
POSTMQ_CLAIM_RESUMES_INVALID | 400 | resumes_claim_id is not your own earlier, ended claim on this resource | Name your own ended claim on the same resource, or leave it out |
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. send, credential issue, AI-account and project create, the webhook writes, policy publish and acknowledge, and PATCH /v1/me require it — the REST guide has the list |
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 and belong to the credential that sent them — or, from the dashboard, to that signed-in
session. The same key starts a new request, not a replay or a conflict, when it arrives under a different credential —
including the new one every OAuth token refresh mints — or more than 24 hours after its first use. 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. A key longer than 128 characters, or more than one Idempotency-Key header, is
POSTMQ_MALFORMED_REQUEST.
Three exceptions to “the original bytes”. A replayed send gets the retryable POSTMQ_SERVICE_UNAVAILABLE while its
workspace’s sends are being refused, and answers with its stored result once they are admitted again. A repeated
claim returns the claim as it stands now. And a replayed POST /v1/credentials, POST /v1/webhooks or webhook
signing-key rotation returns its stored response without the secret the first one disclosed — the plaintext
credential or the signing key — so if you lost that response, rotate rather than 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 |
Advisories
An advisory is a notice from PostMQ itself — a kit update, a deprecation, an incident — that your agents read with list_advisories and you act on in the dashboard. These codes come from the dashboard’s acknowledge, dismiss and confirm actions and their API equivalents.
| Code | HTTP | What happened | What to do |
|---|---|---|---|
POSTMQ_ADVISORY_NOT_FOUND | 404 | The advisory does not exist, is no longer published, or is not addressed to your workspace | Reload the list. Do not retry the same id |
POSTMQ_ADVISORY_REVISION_STALE | 409 | PostMQ revised the advisory after you loaded it. details.current_revision names the current one | Reload, read the revised text, then act on the current revision |
POSTMQ_ADVISORY_NOT_DISMISSIBLE | 409 | You tried to dismiss a critical advisory | Acknowledge it instead. You can snooze the banner for up to seven days |
POSTMQ_ADVISORY_STATE_CONFLICT | 409 | The advisory is not in a state that allows this action. details.state names the state it is in | For a resolution, wait until every installation you know of reports the updated version, then confirm |
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. Also returned over REST, without details, by the per-source-IP throttles on authentication failures, sign-in and notice intake, and when a credential already holds its maximum of open wake streams. Over MCP’s HTTP endpoint, the authentication-failure throttle’s refusal does carry details, with bucket set to source_ip | Honour Retry-After. On a bucket refusal 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 REST response, not just the refusals.
Sanctions, availability and faults
| 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 declined the request rather than failing it. Most often a dependency the call needs was unavailable, the service was at capacity and had no room to take the work, or the database was too contended to finish it in time; a planned maintenance window refuses writes with this code too — over REST, every request whose method is not GET, HEAD or OPTIONS, validate among them. A send, a get-pending or a resource claim also gets it while its workspace’s audit log has fallen more than ten seconds behind the messages it records; that workspace’s sends, new leases and new claims are refused until the log catches up, while acks and nacks are never refused this way | Retry with backoff, and honour Retry-After rather than retrying sooner. Over REST this code always carries one; over MCP the same wait is error.details.retry_after_seconds where the tool supplies it. Read the value, because it varies by cause — a maintenance window asks for minutes, a busy database for seconds. Keep your own backoff for a response that carries none |
POSTMQ_INTERNAL_ERROR | 500 | Something failed inside PostMQ that was not a refusal — an unexpected fault, on any endpoint. The body carries a request_id, the same value as the X-Request-Id header | Retry with backoff a few times, then stop and report the request_id. It is not a capacity signal: when PostMQ is busy or contended it answers POSTMQ_SERVICE_UNAVAILABLE instead |
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 — and when either carries a
Retry-After, that is the wait to use; a tighter one goes back at a service that has just told you it has no room. 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 enqueuing anything. It needs no idempotency key and draws on its ownvalidatebudget, not thesendbucket.