Docs

Rate limits

Five per-tenant budgets, a per-IP budget on the public endpoints, and a handful of per-operator buckets. All of them scale with the tenant's licence tier.

Rate limiting on the X-Radius API is not one number. It is a small set of fixed one-minute windows, each bounding a different cost, and a request can be counted by more than one of them at once.

Every limit below is a per-minute counter in a fixed window, shared across API replicas through Redis so the budget is the whole instance's, not one process's.

What a 429 looks like

{
  "error": {
    "code": "ERR_RATE_LIMITED",
    "message": "Too many requests. Please slow down and try again shortly.",
    "request_id": "c41e77b920",
    "details": { "reason": "rate_limited" }
  }
}

There is no Retry-After header and no header reporting remaining budget. The window is one minute, so a client that waits a minute is always inside a fresh window; backing off exponentially from a few seconds is fine and kinder.

The details.reason is always the literal rate_limited. It does not name which bucket you exhausted — infer that from the endpoint you called.

The five per-tenant budgets

These bound an authenticated tenant, keyed on the tenant for a manager credential. They are the ones an integration meets.

Bucket Default What it counts Environment knob
t_mutate 300/min Every non-GET on the authenticated surface. Mounted once for the whole group, so no write endpoint is unbounded. RL_TENANT_MUTATE
t_values 10/s The dashboard value poll (GET /admin/dashboards/me/values and the tile refreshes). RL_TENANT_VALUES
t_heavy 30/min Expensive reads and renders: reports, export and import enqueues, PDF and design renders, backups, bulk apply. RL_TENANT_HEAVY
t_probe 10/min Operator-triggered network probes at third-party targets: NAS ping, NAS and SSH tests, SNMP interface walks, console mints. RL_TENANT_PROBE
t_egress 5/min One request that fans out to many outbound messages: announcements, Telegram broadcast, webhook test and replay, notification-rule bulk create. RL_TENANT_EGRESS

t_mutate passes GET, HEAD and OPTIONS straight through, so reads cost it nothing. The other four are stacked on individual routes next to their permission check, which means a single heavy write is counted by t_mutate and by t_heavy. Budget accordingly: 30 report requests per minute will also have consumed 30 of nothing, since reports are GETs, but 30 export enqueues consume 30 of t_heavy and 30 of t_mutate.

t_values is counted in the same fixed one-minute window as the rest, so its ceiling per window is ten times sixty, or 600. It exists because t_heavy did not fit it: heavy is 30 per minute for the whole tenant and was sized for a handful of administrators opening report pages, while the dashboard values route is polled on a timer by every signed-in manager. A twenty-seat tenant would exhaust heavy in a single poll cycle.

Budgets scale with the licence tier

The numbers above are the reference. What a tenant actually gets is the reference scaled by its licence resource-share tier:

Tier Share of the reference t_heavy in practice
small 25% 8
medium 50% 15
large 100% 30
xl 200% 60
max 400% 120

Every one of the five buckets scales by that same share, so multiply any reference above by it. A small-tier tenant gets 75 writes a minute against the 300 t_mutate reference, and 2 egress calls against 5.

Scaling rounds up, never to zero — a 5-per-minute reference at 25% is 2, not 1. The tier is derived from the licensed subscriber cap unless an operator pins it: up to 100 subscribers is small, up to 1,000 medium, up to 10,000 large, and anything above that or an uncapped licence is xl. The max tier is explicit-only.

The split is deliberate and one-directional: the operator sets the reference, the licence sets the slice of it, and a tenant can set neither. There is no API call that raises your own ceiling.

One exception is worth knowing: a subscriber-portal credential is bucketed per subscriber, not per tenant, and is not tier-weighted. A tenant-wide portal ceiling would let one busy evening of voucher recharges return 429 to the entire subscriber population, so one abusive subscriber is throttled instead.

Per-operator budgets on sensitive endpoints

A handful of authenticated endpoints carry their own bucket keyed on (tenant, manager) rather than on the tenant. They exist because the endpoint is an oracle as much as an operation — a code-guessing surface, an id-space probe, or a credential mint — and a per-tenant budget would let one operator spend the whole site's.

Bucket Default Endpoints Knob
user_list 240/min GET /users RL_USER_LIST_PER_MIN
user_resolve 120/min GET /users/resolve RL_USER_RESOLVE_PER_MIN
card_search 120/min GET /cards/search RL_CARD_SEARCH_PER_MIN
card_timeline 120/min GET /cards/{id}/timeline RL_CARD_TIMELINE_PER_MIN
card_redeem 60/min POST /cards/redeem, /admin/cards/redeem-otc, /cards/redeem-create RL_CARD_REDEEM_PER_MIN
card_wallet_redeem 60/min POST /admin/cards/redeem-to-wallet RL_CARD_WALLET_REDEEM_PER_MIN
user_login_as 30/min POST /users/{id}/login-as RL_USER_LOGIN_AS_PER_MIN
account_reauth 10/min POST /auth/account/password, /2fa/disable, /2fa/recovery-codes RL_ACCOUNT_REAUTH_PER_MIN
api_token_reveal 10/min POST /developer/api-tokens/{id}/reveal RL_API_TOKEN_REVEAL_PER_MIN

These are flat — the licence tier does not scale them, because they bound a security property rather than a resource share.

Note that card_timeline counts a denied lookup too. The limiter is mounted ahead of the visibility check on purpose: behind it, a probe of the id space would have been completely unmetered, which is the exact thing the bucket exists to stop.

The public endpoints

Unauthenticated routes are throttled per source IP, at 20 per minute by default (PUBLIC_RATE_LIMIT / PUBLIC_RATE_WINDOW), with each endpoint on its own named bucket so login and tenant lookup do not share a budget. Login, self-service registration, host resolution, slug availability, captcha, payment returns and the public site forms are all in this class.

The zero-login portal exchange endpoints get a separate, higher budget — 120 per minute (PORTAL_ZEROLOGIN_RATE_LIMIT) — because they are called through a relay or from behind site NAT, where every legitimate subscriber shares one source IP.

Writing a client that does not trip

  • Spread work over the window. The counter is a fixed window, not a token bucket, so 300 writes in the first second and 300 in the next are two full windows back to back. A steady rate is strictly better than bursts.
  • Do not page for bulk data. A loop over a large table burns t_mutate budget on nothing useful and will hit the offset cap anyway. Enqueue an export job instead.
  • Batch the bulk endpoints. Most collections have a bulk verb that takes an array of ids. One POST /users/bulk-disable with 500 ids costs one request against t_mutate; five hundred individual calls cost five hundred and will not fit in a minute.
  • On 429, back off and retry the same bytes. If the operation carries a request_id, reuse it — see idempotency. A 429 is the one error class where retrying identical bytes is the correct response.
  • Treat probe and egress endpoints as manual actions. At 10 and 5 per minute for a standard licence, they are sized for an operator clicking a button, not for a polling loop.

If your integration genuinely needs more than the tier provides, the ceiling is raised on the licence by the operator of the instance, not through the API.

Last updated

Ask a question

Try it on your own network.

50 subscribers for 7 days. You pay nothing.