Rate limits here are anti-abuse guardrails on the control plane, not usage quotas on your work. Nothing on this page meters how many issues the agent triages or how many CI minutes you burn, those are covered by your seat, and your only variable cost is your own token bill (see BYOK).
What is limited is how fast a client may call the API.
The buckets
Four sliding windows, all one minute wide. Every budget on this page is declared
once, in apps/api/src/middleware/rate-limit.ts, and the constant is named next
to the number so a change there is one search away from this page.
| Bucket | Keyed on | Budget | Constant |
|---|---|---|---|
| Reads | Your token or session | 600 / minute | READ_CAPACITY |
| Writes, per conversation | Your token or session, and the conversation | 120 / minute | WRITE_CAPACITY |
| Writes, per token | Your token or session | 600 / minute | TOKEN_WRITE_CAPACITY |
| Pre-auth | Caller IP | 1200 / minute | PREAUTH_CAPACITY |
These are machine-shaped numbers on purpose. The clients of this API are agents: a box polling a run, a janitor sweeping a repo, an operator draining a stuck queue, the MCP surface you point your own agent at. All of that is legitimate, authenticated, bursty automation, which is why the read and write budgets are 600 a minute and not the one-request-a-second a human-shaped API would set.
The per-IP bucket is applied before your credential is resolved. That is deliberate: it bounds credential probing and keeps an unauthenticated flood from reaching the database at all. It stays strictly above every per-token budget, so the fair-share bucket is always the one that binds first for a caller who has authenticated; a pre-auth number at or below the token caps would quietly become the real ceiling for everybody.
Read and write are separate budgets, not a shared pool. Spending your writes does not stop you polling.
Why writes have two numbers
A write is charged twice: once to the conversation it belongs to, once to the token overall. The narrow bucket is checked first, so one busy conversation cannot drain the budget of the others beside it.
Naming a conversation is how you get your own fair share. Today the only surface
that does is MCP: pass conversation_ref as an argument and that conversation
gets its own 120 a minute (WRITE_CAPACITY), up to the token ceiling of 600
(TOKEN_WRITE_CAPACITY). Everything else shares one anonymous conversation, so
its effective limit is the 120: every REST write, and any MCP call that does not
carry the argument.
Because two buckets can bind, the RateLimit-* headers report whichever one runs
out first: seeing 120 on one write and 600 on the next is correct, not a bug.
What counts as a write
By default, the class is the HTTP method: POST, PUT, PATCH and DELETE
are writes, everything else is a read.
MCP is the exception. Every MCP call is a single POST /v1/mcp, so the
method rule alone would charge read verbs like box list to the write buckets:
out of one conversation's 120 a minute (WRITE_CAPACITY) rather than the 600 a
minute (READ_CAPACITY) a read is entitled to, and against the fair share the
conversation needs for actual work. MCP requests are classified by the verb's
scope instead: a read verb spends from the read budget.
Endpoints with their own limit
These sit outside the per-token buckets above, each with its own per-IP window and its own constant.
| Endpoint | Limit | Constant |
|---|---|---|
GET /v1/public/oss/:owner/:repo/stats |
60 / minute per IP | DEFAULT_RATE_PER_MINUTE (routes/public-oss.ts) |
GET | POST /unsubscribe |
30 / minute per IP | DEFAULT_RATE_PER_MINUTE (routes/unsubscribe.ts) |
GET /v1/openapi.json |
30 / minute per IP | OPENAPI_RATE_PER_MINUTE (routes/openapi.ts) |
POST /v1/runners/enroll |
60 / minute per IP | ENROLL_RATE_PER_MINUTE (routes/runners.ts) |
POST /v1/runners/creds/reissue |
60 / minute per IP | BOX_BEARER_RATE_PER_MINUTE (routes/runners.ts) |
POST /v1/runners/hardening |
60 / minute per IP | BOX_BEARER_RATE_PER_MINUTE (routes/runners.ts) |
Correction: the box lanes are capped
An earlier version of this page said POST /v1/runners/hardening was not
rate-limited, and gave two reasons: that boxes behind one NAT would be refused by
a per-IP cap, and that "the bearer is checked before any lookup, so it is not a
flood surface either". Both halves are wrong, and the second was wrong when it
was written. It is corrected here rather than deleted, because it was published.
POST /v1/runners/hardening and POST /v1/runners/creds/reissue are each capped
at 60 requests a minute per IP (BOX_BEARER_RATE_PER_MINUTE,
apps/api/src/routes/runners.ts), and the bucket is charged before anything else
in the handler runs. The check that happens before the database lookup grades the
shape of a dev_run_… bearer, not its identity, so Bearer dev_run_<junk>
passes it and buys one credential-hash lookup per attempt. Uncapped, one address
could loop that without bound and without a record; the cap makes each attempt a
counter hit instead. The three box lanes hold separate buckets, so a flood
against one cannot lock a legitimate box out of another.
The shared-egress concern is real, and it set the number rather than removing the cap. A box reports hardening once per install and rolls its credential on an hourly timer, with retries floored at one a minute, so even a crash-looping box spends about two requests a minute and a rack of them behind one NAT spends a small multiple of that. A cap of 60 leaves several times the headroom the busiest shared-egress site we have measured needs. If yours needs more, tell us and we will raise it for you.
Watching a run without spending your whole read budget
A once-a-second poll spends 60 reads a minute, a tenth of the 600 in
READ_CAPACITY, on a single run. Ten watched runs and the budget is gone, and a
fleet rollout (box update) runs for minutes to hours, so this is the shape of
spend that grows with your fleet rather than a cap you hit once.
So the run tail takes a long poll. Send wait_ms and the request is held
open until the first new event lands, up to 25 seconds, and answers the instant
one does:
curl "https://api.developerz.ai/v1/tasks/$TASK/events?since_seq=$CURSOR&wait_ms=25000" \
-H "Authorization: Bearer dev_pat_…"
{ "run_id": "run_…", "events": [], "next_since_seq": 412 }
Over MCP it is the same knob on the same capability:
{ "action": "run_tail", "run_id": "run_…", "since_seq": 412, "wait_ms": 25000 }
| Watching one run for an hour | Requests spent |
|---|---|
| polling every second | 3600, a tenth of your read budget held for the whole hour |
wait_ms=25000 on an idle run |
about 144, well under 1% of the budget |
Five rules worth knowing before you build on it:
- An empty page after a wait is not an error. It means nothing happened in
that window. Re-send with the same cursor.
next_since_seqechoes your own cursor back for exactly this reason, so a caught-up watcher never rewinds to the head of the run. - Omit it, or send
0, and nothing changes. The call answers immediately, exactly as it did before the knob existed. - A refusal is never held. An unknown task, or a run that is not yours, is
answered straight away rather than after the wait. The shape depends on the
transport: over REST it is a
404with a problem+json body, and over MCP it is a tool result flaggedisErrornaming the run, because a JSON-RPC result carries no HTTP status. - A run that already ended settles the wait too. Once its final event has
landed nothing can arrive after your cursor, so the call answers an empty page
at once instead of holding the window open. That is your cue to stop: read
GET /v1/tasks/$TASK(or MCPtask run_status) and you will see the run finished. - Above 25000 is refused, not clamped. A long poll writes nothing until it answers, so it cannot survive the 30-second idle timeouts common to proxies and HTTP clients the way a streaming connection can.
Why a long poll and not a bigger budget
The other way to fix this would be a separate bucket just for watching. We did
not build one, on purpose, and raising READ_CAPACITY to 600 did not change the
argument: a bigger allowance hands you more requests to spend without making any
of them carry more, we would still answer 60 empty reads a minute per watcher,
and you would have another budget to reason about. Fewer requests that
each carry an answer is the better trade for both sides. If you are an agent, it
is not close: every poll is a tool call whose result lands in your context, so 60
empty answers a minute is a context bill long before it is a rate-limit one.
It is a POST, and it always will be
There is no server-push transport on the MCP surface. Every MCP call is one
JSON-RPC POST /v1/mcp and a tool result is one message, so a verb cannot hold a
stream open. wait_ms is the whole answer there, and it is not a stopgap for a
subscription that is coming later.
REST has both shapes, because they are two different capabilities rather than one
answer in two envelopes. GET /v1/tasks/:id/events is the page you poll;
GET /v1/fleet/runs/:runId/events is the Server-Sent Events stream you subscribe
to, resumable with Last-Event-ID. Use the stream if you can hold a connection
open. Use wait_ms if you cannot, which includes every agent talking to us over
MCP.
Every response tells you what is left
You do not have to count your own calls, and you should not have to get throttled
to discover the cap. Every /v1 response produced behind the auth guard carries
three headers:
HTTP/1.1 200 OK
RateLimit-Limit: 600
RateLimit-Remaining: 598
RateLimit-Reset: 41
| Header | Means |
|---|---|
RateLimit-Limit |
the budget of the bucket this request was charged against |
RateLimit-Remaining |
what is left in it after this request |
RateLimit-Reset |
seconds until a slot frees, never a timestamp |
Two things follow from "the bucket this request was charged against":
- A read and a write report different limits, because they are different budgets.
Seeing
600on aGET(READ_CAPACITY) and120on thePOSTright after it (WRITE_CAPACITY, the conversation's fair share) is correct. - On
POST /v1/mcpthe numbers follow the verb, not the method, exactly the way the bucket does.tools/listreports the read budget.
A request that fails to authenticate reports the pre-auth per-IP bucket instead, because that is the only one it spent.
Two /v1 endpoints are mounted ahead of the guard and send no RateLimit-*
headers at all: GET /v1/openapi.json and the public OSS stats badge. Both are
publicly cacheable, so a per-caller counter sitting behind a shared cache would
report a wrong number to everyone reading it. Treat their absence as the
contract, not as a failed request.
What a 429 looks like
Errors are RFC 9457 problem details:
HTTP/1.1 429 Too Many Requests
content-type: application/problem+json
retry-after: 37
RateLimit-Limit: 600
RateLimit-Remaining: 0
RateLimit-Reset: 37
{
"type": "https://api.developerz.ai/problems/rate-limit",
"title": "Too Many Requests",
"status": 429,
"detail": "rate limit exceeded",
"instance": "/v1/repos",
"extensions": { "retry_after": 37 }
}
Back off for retry-after seconds. The same number is repeated inside
extensions.retry_after so a client that only parses the body still has it, and
in RateLimit-Reset, because for a sliding window they are the same instant.
Two places the headers are not sent
GET /v1/openapi.jsonand the public OSS stats badge. Both are cacheable by anyone, and a per-caller counter stored in a shared cache is a wrong number served to everybody behind it until the entry expires. They still have their own per-IP limits, listed above.- Anything spelled
X-RateLimit-*. An earlier version of the API reference documentedX-RateLimit-*headers and a 1000-per-minute per-account limit. Neither ever existed. The headers above are the real ones and use the names from the IETFRateLimitheader fields draft, with noX-prefix.
Limits you may feel that are not rate limits
Two other ceilings are easy to mistake for throttling:
- Concurrency. One paid seat admits one concurrent AI-dev runner. Work above that waits in the queue rather than being rejected. See billing.
- Spend caps. A monthly USD cap bounds the orchestration we do for you, not your model bill, and pauses dispatch when it is reached. Your inference spend is billed by your own provider on your own key; cap it in their console. See billing.
Both are described on the billing page. Neither produces a 429.