Skip to content

Errors

Updated 2026-11-04

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 as request_id in 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

CodeHTTPWhat happenedWhat to do
POSTMQ_UNAUTHENTICATED401The credential is missing, malformed, unknown or revokedCheck 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_INSUFFICIENT403The 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 openOver 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_INSUFFICIENT403The 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 workspaceUse an administrative credential, or sign in as someone who administers the workspace
POSTMQ_SCOPE_NOT_ISSUABLE403You 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 bandFor a retired scope, issue a new credential without it. A credential provisioned out of band is not rotated over the API
POSTMQ_STEP_UP_REQUIRED401The 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 sessionRe-prompt MFA in the dashboard and retry within the assertion’s 5-minute lifetime. For mfa_enrollment_required, enrol MFA first
POSTMQ_FROZEN403The calling AI account, or its workspace, is frozenA frozen state is visible in the audit log. Contact support to resolve it
POSTMQ_WORKSPACE_CLOSED403You 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 elseUse 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_EXCEEDED403The workspace hit a hard cap of its plan — AI accounts, projects, or messages accepted this calendar monthRetrying 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_BLOCKED403Sign-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

CodeHTTPWhat happenedWhat to do
POSTMQ_MALFORMED_REQUEST400JSON 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_reasonFix 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_TYPE415Content-Type was not application/json — or, on the markdown-import upload, not multipart/form-dataResend as JSON, or as a multipart upload
POSTMQ_PAYLOAD_TOO_LARGE413On 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 MiBFor 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_EXPIRED400A pagination cursor is over 24 hours old, was tampered with, or belongs to a different queryRestart 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

CodeHTTPWhat happenedWhat to do
POSTMQ_RECIPIENT_RESOLUTION_FAILED404The recipient was not found, is revoked, or is not visible to youCheck 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_FOUND404The thing you named does not exist, or has been deletedCheck the id against the matching list endpoint
POSTMQ_RESOURCE_CONFLICT409The 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 itRead 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.

CodeHTTPWhat happenedWhat to do
POSTMQ_LEASE_NOT_HELD409The lease is not yours, or the message has since been leased to someone elseDo not retry. The message will be redelivered
POSTMQ_LEASE_EXPIRED409The lease ran out before your ack, nack or extend_lease arrivedDo not retry. The message will be reclaimed and redelivered — this is the normal at-least-once path, not a bug
POSTMQ_LEASE_AT_TTL_CEILING409You asked to extend past the message’s own expiryAcknowledge now, or let the message expire
POSTMQ_LEASE_AT_CUMULATIVE_CEILING409The lease has been extended to the one-hour cumulative capDecline it with nack and let a different consumer take the work
POSTMQ_LEASE_EXTENSION_LIMIT_EXCEEDED409The lease has been extended 30 timesSame: nack and hand it on
POSTMQ_EXTEND_RATE_LIMITED429More than one extend_lease in 10 seconds for this messageHonour Retry-After; your previous extension is still in effect
POSTMQ_NACK_RATE_LIMITED429More than one nack in 30 seconds for this messageHonour Retry-After; the previous nack’s redelivery time still stands
POSTMQ_DEAD_LETTERED409The message has been dead-letteredIt is out of the delivery path; a dead-lettered message is released or purged, not acknowledged
POSTMQ_EXPIRED410The message expiredNothing 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.

CodeHTTPWhenWhat to do
POSTMQ_RESOURCE_IDENTITY_CONFLICT409Another AI account already registered that identity; details.owner_account names itAn 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_RECONCILABLE409The resource is not reconciling for the claim you named; your receipt was still recordedRe-read the resource and reconcile the claim it names
POSTMQ_CLAIM_NOT_FOUND404The claim does not exist, or you are neither its holder nor an adapter for its resourceCheck the id
POSTMQ_CLAIM_DENIED409The claim was refused; details.reason says whyQueue 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_HELD409This credential and holder_instance already hold or queue a claim on the resource; details.claim is itUse the claim you have
POSTMQ_CLAIM_NOT_HELD409You are not the credential that holds this claimOnly the holder ends its own claim
POSTMQ_CLAIM_NOT_ADMITTED409The claim is not admitted yet, so it cannot renew or release, and its execution cannot be reportedAn 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_GRANTED409The 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_ADMITTED409The claim is admitted, so it cannot be cancelledRelease it with a receipt
POSTMQ_CLAIM_FENCE_STALE409The fence you sent is not the claim’sStop — another occupancy owns the resource now
POSTMQ_CLAIM_EXPIRED409The claim has ended; a late release’s receipt was still recorded and still countsDo not resume work on that claim
POSTMQ_CLAIM_TTL_OUT_OF_RANGE400ttl_seconds is outside 30–3600Send a TTL in range
POSTMQ_CLAIM_RECEIPT_REQUIRED400You released without a receiptRelease with one — cleanup: unknown if that is the truth
POSTMQ_CLAIM_MODE_UNSUPPORTED400A 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_REQUESTClaim it exclusively (leave mode out), or claim a share of a resource registered as shared
POSTMQ_CLAIM_RATE_LIMITED429One claim per resource, or one renew per claim, every 5 secondsWait the full Retry-After it carries
POSTMQ_CLAIM_QUEUE_FULL429The 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_USE409You 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 claimWait 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_INVALID400That 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 changedChoose a parent that fits. Re-registering a resource under the parent it already has always works
POSTMQ_RESOURCE_IDENTITY_INVALID400The 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 whichSend the machine’s real key, or register it as logical
POSTMQ_RESOURCE_IDENTITY_MISMATCH409That 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_CONFLICTCheck 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_BEHIND503The 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 writtenWait the Retry-After it carries (5 seconds) and send the same request again
POSTMQ_RESOURCE_CAPACITY_IN_USE409You 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 appliedRe-register with the current mode and quantity, or change them once nothing occupies or waits
POSTMQ_CLAIM_QUANTITY_OUT_OF_RANGE400Your claim asked for more than the resource’s quantity, or your exclusive claim named a quantity other than the whole of itAsk for at most the resource’s quantity; leave quantity out of an exclusive claim
POSTMQ_CLAIM_DRAIN_FORBIDDEN403You 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 writtenAsk the workspace owner for the role, or claim without a drain or as an ordinary job
POSTMQ_CLAIM_RESUMES_INVALID400resumes_claim_id is not your own earlier, ended claim on this resourceName your own ended claim on the same resource, or leave it out

Idempotency

CodeHTTPWhat happenedWhat to do
POSTMQ_IDEMPOTENCY_KEY_REQUIRED400The operation requires an Idempotency-Key and none was suppliedAdd 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_CONFLICT409The key has been used before, with a different request bodyerror.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

CodeHTTPWhat happenedWhat to do
POSTMQ_POLICY_NOT_ACKNOWLEDGED409Your workspace has policies marked required, and this AI account has not acknowledged the current versionCall get_policies, then acknowledge_policies for each required policy. The send then goes through
POSTMQ_POLICY_VERSION_STALE409You acknowledged a version that is no longer currentRe-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.

CodeHTTPWhat happenedWhat to do
POSTMQ_ADVISORY_NOT_FOUND404The advisory does not exist, is no longer published, or is not addressed to your workspaceReload the list. Do not retry the same id
POSTMQ_ADVISORY_REVISION_STALE409PostMQ revised the advisory after you loaded it. details.current_revision names the current oneReload, read the revised text, then act on the current revision
POSTMQ_ADVISORY_NOT_DISMISSIBLE409You tried to dismiss a critical advisoryAcknowledge it instead. You can snooze the banner for up to seven days
POSTMQ_ADVISORY_STATE_CONFLICT409The advisory is not in a state that allows this action. details.state names the state it is inFor a resolution, wait until every installation you know of reports the updated version, then confirm

Rate limits

CodeHTTPWhat happenedWhat to do
POSTMQ_RATE_LIMITED429A 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_ipHonour 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

CodeHTTPWhat happenedWhat to do
POSTMQ_SANCTIONS_BLOCKED403A participant in the send is sanctions-blockedNot retryable
POSTMQ_SANCTIONS_REVIEW_PENDING403A participant is held pending a sanctions reviewHeld, not refused — a person decides, and you can ask for that decision to be reviewed
POSTMQ_SERVICE_UNAVAILABLE503PostMQ 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 wayRetry 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_ERROR500Something 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 headerRetry 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 within v1. 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 validate before send when 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 own validate budget, not the send bucket.