Rate Limits
Proxio API rate limits are documented and per-key. 120 requests per minute by default, a per-key override, and 60 per minute for unauthenticated discovery. Every response carries X-RateLimit-* headers, and 429s include Retry-After.
Rate limits on the Proxio API are documented, per-key, and visible on every response. You never have to guess your budget, it's in the headers.
The numbers
| Scope | Limit | Applies to |
|---|---|---|
| Default per key | 120 requests / 60s | Every authenticated /v1 endpoint. |
| Per-key override | Custom | Set on an individual key to use less than the default. When set, it replaces the default for that key. Raising a key above the default is done by Proxio support, not self-service. |
| Discovery | 60 requests / 60s per IP | The unauthenticated discovery route (a GET on the base URL) and GET /openapi.json. |
The budget is per API key, not per account. A read-only key powering a dashboard and a CI key running jobs have independent budgets, so one can't starve the other. Every request counts against the limit, not just failures.
The default of 120 per minute is roughly two requests per second sustained. You can lower a key's limit yourself in Settings → API keys, useful for a key you want to keep deliberately gentle. If a heavy integration needs more than the default, contact support to raise it, no code change is required on your side once it's set.
Endpoint-specific limits
A few write endpoints carry an extra throttle on top of the per-key budget:
| Operation | Extra limit | Scope |
|---|---|---|
| Whitelist additions (add a binding) | 30 / 60s | per account |
| Session rotation (rotate one or all) | 30 / 60s | per credential |
Both return RATE_LIMITED (429) with a
Retry-After header when exceeded, just like the per-key limit.
Headers
Once your key authenticates, every /v1 response carries the current limit
state, success or error:
| Header | Example | Meaning |
|---|---|---|
X-RateLimit-Limit | 120 | Your budget for the current window. |
X-RateLimit-Remaining | 118 | Requests left in the current window. |
X-RateLimit-Reset | 1752745860 | Unix seconds when the window fully resets. |
Retry-After | 12 | On 429, and also on a 502 or 503. Seconds to wait before retrying. |
Watch X-RateLimit-Remaining and slow down as it approaches zero, rather than
waiting to be told no.
Auth failures carry no rate-limit headers
The limit is measured per API key, so it can only be evaluated after the key
is identified. Authentication runs first, which means a response that fails at
that stage has no X-RateLimit-* headers at all:
UNAUTHENTICATED,
INVALID_API_KEY,
EXPIRED_API_KEY,
REVOKED_API_KEY,
ACCOUNT_SUSPENDED, and
IP_NOT_ALLOWED. Read the headers
defensively and fall back to your own backoff when they're absent. Everything
past authentication, including 404s, validation errors, and 429s, does
carry them.
A rejected request still costs budget, unless the rejection is the limit itself
A request is counted the moment the key is authenticated, before the scope
check runs. So a call with a valid key but the wrong scope returns
INSUFFICIENT_SCOPE (403) and still
consumes one unit of the window. A loop retrying a scope error will exhaust the
budget and start getting 429s instead. Fix the key's scopes rather than
retrying.
The one rejection that does not cost anything is a 429 from this same
limiter. A request the rate limiter itself turns away is given its unit back,
so retrying a 429 never digs the hole deeper the way retrying a scope error
does. This is also what makes honoring Retry-After reliable: the wait it
tells you is solved to be the point your budget has genuinely recovered
enough to admit the next request, so a client that backs off for exactly that
long, sends nothing in between, and then retries once succeeds. It's not a
rounded-down guess.
What a 429 looks like
When you exceed the limit, the API returns 429 with the standard error
envelope and a Retry-After header:
{
"error": {
"code": "RATE_LIMITED",
"message": "Rate limit exceeded for this API key.",
"doc_url": "https://docs.proxio.net/docs/api/errors#rate_limited",
"request_id": "req_8Ke2jP4mQ"
}
}RATE_LIMITED is retryable: honor Retry-After, then resend the same request.
Retry-After on 502 and 503
UPSTREAM_ERROR (502) and
SERVICE_UNAVAILABLE (503) also
carry a Retry-After header now, but just as retryable, and this is the same
header telling you the same thing: wait this long, then resend. Unlike a 429,
a 502 or 503 does spend a unit of your budget: only a request refused by
the limiter itself is refunded, so a run of upstream failures still eats into
what you can send. In
practice that's a flat 2 seconds on a 502 and 5 seconds on a 503, a
502 is usually one bad upstream hop that clears almost immediately, a 503
means something took longer to recover. Read the header rather than
hardcoding either number, a specific failure is free to send its own value
and the header is what actually reaches you. The retry loop below, built for
429, works unmodified for these two: swap the status check and it's the
same pattern.
Handling 429 in code
Respect Retry-After and back off. These helpers retry a GET a few times,
sleeping for the server-provided delay:
# curl --retry treats 429 as retryable and honors Retry-After automatically.
curl --retry 5 --retry-all-errors --retry-delay 0 \
"https://dashboard.proxio.net/api/v1/services?limit=20" \
-H "Authorization: Bearer pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"import time
import requests
BASE = "https://dashboard.proxio.net/api/v1"
API_KEY = "pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
def get_with_retry(path, params=None, max_attempts=5):
for attempt in range(max_attempts):
resp = requests.get(f"{BASE}{path}", headers=HEADERS, params=params, timeout=15)
if resp.status_code != 429:
resp.raise_for_status()
return resp.json()
# Honor Retry-After; fall back to exponential backoff if it's absent.
wait = int(resp.headers.get("Retry-After", 2 ** attempt))
time.sleep(wait)
raise RuntimeError("Rate limited after retries")
print(get_with_retry("/services", {"limit": 20})["data"])const BASE = "https://dashboard.proxio.net/api/v1"
const API_KEY = "pxo_9fJ2kQ7xR4mN8pL1dW6vB3cH5tZ0aYqS7dK2mN9x"
const HEADERS = { Authorization: `Bearer ${API_KEY}` }
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function getWithRetry(path, maxAttempts = 5) {
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const res = await fetch(`${BASE}${path}`, { headers: HEADERS })
if (res.status !== 429) {
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
// Honor Retry-After; fall back to exponential backoff if it's absent.
const wait = Number(res.headers.get("Retry-After") ?? 2 ** attempt)
await sleep(wait * 1000)
}
throw new Error("Rate limited after retries")
}
const { data } = await getWithRetry("/services?limit=20")
console.log(data)Add jitter for fleets
If many workers share one key, add a small random jitter on top of
Retry-After so they don't all retry on the same tick. Spreading heavy
automation across multiple keys also
multiplies your total budget, since limits are per key.
Related pages
Errors
The complete Proxio API error catalog. Every error returns a stable UPPER_SNAKE code, an HTTP status, a request_id, and a doc_url that deep-links to the matching section here. Includes retry guidance per code family.
Pagination
The Proxio API uses cursor pagination everywhere. Pass limit and cursor, read meta.next_cursor, and loop until it's null. Includes a copy-paste pagination loop in Python and Node.js.

