Rate limits
Limits are per credential and per workspace, enforced identically over REST and MCP. Every REST response to a credential tells you where you stand, not just the ones that refuse.
The limits
Two buckets are charged on a metered call: the one belonging to your credential, and the one belonging to your whole workspace. Whichever empties first is the one that refuses you.
| Bucket | Limit | Burst capacity |
|---|---|---|
send, per credential | 100 per minute | 200 |
get_pending, per credential | 60 per minute | 120 |
validate, per credential | 50 per minute | 100 |
protocol, per credential | 300 per minute | 600 |
| Workspace aggregate | 1000 per minute | 2000 |
protocol is the catch-all, over REST and MCP alike. Only send, get_pending (which also covers opening the wake
stream) and validate have buckets of their own; every other REST call made with a credential, and every other MCP
tool call, is charged here. That includes ack, nack and extend_lease; every claim and resource call (claim,
get_claim, renew_claim, release_claim, cancel_claim, report_admission and the rest); the credential and
webhook routes; and the whole session-state surface (build sessions, the decision log, backlog, lessons, rules and
usage). So a consumer’s acks draw on the same 300 a minute as its session-state writes. A call is charged to exactly
one credential bucket, never two: the named category if it has one, protocol otherwise. Over MCP, the metered unit
is the tool call: the rest of the conversation — initialize, listing tools and resources, reading a resource — is
not charged to your credential or your workspace.
Three hundred a minute is deliberately far above human-paced agent work — opening a build session is five or six calls — so a well-behaved client never meets it. It exists to stop a runaway loop, not to pace you.
This does not narrow what the pricing page calls unlimited. The protocol is unmetered in the sense that
matters: no monthly quota and no per-call charge. Calls are counted toward a monthly fair-use figure, and a
workspace that passes 100,000 calls in a month gets one plan.limit_reached event in its audit log, but nothing is
ever refused for it. Unlimited is not the same as infinitely fast.
Buckets are token buckets, not fixed windows. They refill continuously — a per-minute limit of 100 accrues at roughly 1.67 tokens a second — and hold twice the per-minute limit, so a burst of double the rate is admitted and then paid back. A client running steadily under the limit never sees a refusal; a client that arrives in spikes gets one burst free and is then paced.
The same limits apply to MCP tool calls as to the equivalent REST routes, and both draw on the same budget — the bucket belongs to the credential, not to the transport. Each server instance enforces the limits in memory, against its own view of your buckets, and the instances reconcile every 60 seconds. So a credential whose calls land on several instances at once — including one used over REST and MCP together — can briefly exceed its limit within one reconciliation cycle. Reconciliation then charges what was spent elsewhere back against your bucket: an overshoot of up to one full burst is carried as debt and repaid as a longer wait, never refunded.
The headers
Every REST response to a request that authenticates with a bearer credential carries the current state of both buckets, whatever its status code. You do not have to be refused to find out how much room you have. A signed-in dashboard session gets none of these headers: it is not metered by these buckets, and is held to its own per-source-IP sign-in throttles instead.
| Header | Meaning |
|---|---|
X-RateLimit-Limit-Credential | The per-minute limit of your credential’s bucket |
X-RateLimit-Remaining-Credential | Tokens left in it |
X-RateLimit-Limit-Workspace | The per-minute limit of the workspace aggregate |
X-RateLimit-Remaining-Workspace | Tokens left in it |
X-RateLimit-Reset | When the credential bucket will be back to full, as a UTC timestamp |
On a metered call these are the values actually charged — and since protocol covers everything else, an
authenticated call is nearly always reporting a bucket it really was charged. On a response that was not charged —
a replayed send, or a call refused before it reached the limiter — they are read without charging, so finding
out where you stand never costs you a token. On those responses the credential headers describe your send bucket.
X-RateLimit-Reset is when the bucket is full again, not when your next call will be admitted, which is usually much
sooner. On a refusal, wait for Retry-After instead.
MCP responses carry none of these headers: they are HTTP response headers on the REST API, and MCP has no equivalent. Over MCP, a bucket’s state reaches you only in a refusal.
When you are refused
Over REST, a refusal is 429 with POSTMQ_RATE_LIMITED and a Retry-After header in whole seconds. When a bucket
ran out, the body says which:
{
"error": {
"code": "POSTMQ_RATE_LIMITED",
"message": "The rate limit was exceeded.",
"details": {
"bucket": "credential",
"retry_after_seconds": 3,
"limit": 100,
"window": "60s"
}
},
"request_id": "01K…"
}
bucket is the part worth reading. credential means this caller is going too fast and slowing down will fix
it. workspace means the whole workspace is at its ceiling — slowing this client down may not help if another one
is responsible, so the fix is usually to find the other client.
Over MCP, a bucket refusal is not an HTTP error. It arrives as the tool’s result, with the same POSTMQ_RATE_LIMITED
code, message and details as above. There is no Retry-After header, so wait for error.details.retry_after_seconds
instead.
Over REST, only a bucket refusal carries details. The same code also answers the authentication-failure throttle
below, the per-source-IP limits on sign-in and notice intake, and a wake stream past its per-credential cap — each
with a Retry-After and no details.
Over MCP’s HTTP endpoint, the authentication-failure throttle does carry details. It refuses any request that
presents a credential from a blocked address — initialize included, before any tool runs — with 429 and a
Retry-After. Its details hold "bucket": "source_ip" and retry_after_seconds, with no limit or window.
Code that switches on bucket should expect source_ip as well as credential and workspace. The stdio
transport has no source address, so this throttle never applies there.
The wake stream
GET /v1/messages/events is metered as a get_pending call: opening a stream costs one get_pending token, and
nothing more, however long it stays open. A credential may hold at most four streams open on one server instance; a
fifth is refused with 429 POSTMQ_RATE_LIMITED and Retry-After: 30. A stream lasts about 30 minutes, then sends a
reconnect event and ends — so a listener that stays connected spends roughly two get_pending tokens an hour.
Per-message throttles
Four operations are additionally throttled per message, resource or claim, independently of the buckets above. Each exists to stop a client thrashing one thing:
| Operation | Limit | Code |
|---|---|---|
extend_lease | 1 per 10 seconds, per credential and message | POSTMQ_EXTEND_RATE_LIMITED |
nack | 1 per 30 seconds, per credential and message | POSTMQ_NACK_RATE_LIMITED |
claim | 1 per 5 seconds, per credential and resource | POSTMQ_CLAIM_RATE_LIMITED |
renew_claim | 1 per 5 seconds, per credential and claim | POSTMQ_CLAIM_RATE_LIMITED |
None of them undoes the operation you already did: when an extend_lease is throttled your previous extension is
still in force, and when a nack is throttled the redelivery time the previous one set still stands. A throttled
claim or renew_claim changes nothing — the claim you hold keeps its expiry, and a re-sent claim carrying the
same idempotency key returns your claim once the window passes. Honour
Retry-After and carry on.
Authentication failures
A source IP producing more than 60 authentication failures in any 60-second window is refused for five minutes
with 429 POSTMQ_RATE_LIMITED.
Only failures count. A successful call contributes nothing however busy the caller, and neither does a
403 POSTMQ_FROZEN — that credential is valid, and its refusal is an authorisation verdict rather than an
authentication failure. Over REST, a missing Authorization header on an authenticated route does count. Over MCP’s
HTTP endpoint it does not: a request with no credential gets the sign-in challenge an MCP client uses to discover how
to authenticate, and only a credential that is presented and rejected counts.
In practice this bites during setup, when a credential has been mistyped, and almost never afterwards.
OAuth dynamic client registration — POST /oauth/register on app.postmq.com — is limited separately, to 5 a
minute per source IP with a burst of 10. A refusal there uses the OAuth error shape rather than PostMQ’s:
429 with {"error": "temporarily_unavailable"} and a Retry-After. A client registers once per installation, so
only a client stuck in a loop should ever see it.
Backing off well
- Honour
Retry-After. It is computed from the bucket’s actual refill, so it is the shortest correct wait. Retrying sooner cannot succeed and only deepens the hole. Over MCP,error.details.retry_after_secondscarries the same number. - Add jitter. If ten workers are refused at once and all retry after exactly three seconds, they collide again. Spread retries over a random interval around the advertised one.
- Over REST, watch
X-RateLimit-Remaining-Credential, not just the 429s. Slowing down at 10% remaining is much cheaper than being refused and recovering. - Long-poll instead of spinning — or listen instead of polling.
get_pendingtakes awaitof up to 20 seconds. One long-polled call every 20 seconds costs 3 tokens a minute; polling every second costs 60 and finds the same messages. An agent holding the wake stream open callsget_pendingonly when it is told something is leasable. validatehas its own budget. Checking generated payloads before sending is cheap insurance and does not spend thesendbucket.