Rate limits
Four classes, priced by what a route costs us rather than by a single global number. The limits are published per tier, they are also machine-readable on GET /me, and every response tells you where you stand.
The four classes
Each cell below is a window, not a rate: that many requests may arrive at once, and the sustained rate is the limit divided by the window. A route with no class declared is read.
read
Everything that answers from our own database. The default for any route that does not say otherwise.
| Band | Limit | Window | Sustained |
|---|---|---|---|
| Sandbox | 10 | 1s | 10/s |
| Bronze | 10 | 1s | 10/s |
| Silver | 20 | 1s | 20/s |
| Gold and Platinum | 40 | 1s | 40/s |
Routes: GET /me, GET /balance, GET /ledger, GET /deposits, GET /deposits/{id}, GET /plans/{id}, GET /plans/{id}/price, GET /destinations, GET /orders, GET /orders/{id}, GET /esims, GET /esims/{iccid}
write
The two routes that move money: POST /orders, which spends it, and POST /deposits, which mints the invoice that adds it. Size the budget for both — a batch that tops up mid-run is competing with its own ordering.
| Band | Limit | Window | Sustained |
|---|---|---|---|
| Sandbox | 10 | 5s | 2/s |
| Bronze | 10 | 5s | 2/s |
| Silver | 25 | 5s | 5/s |
| Gold and Platinum | 50 | 5s | 10/s |
Routes: POST /deposits, POST /orders
supplier
Reserved for routes that call the upstream supplier per request. No route uses it today — the limits are published so that an endpoint added to this class later is not a surprise.
| Band | Limit | Window | Sustained |
|---|---|---|---|
| Sandbox | 1 | 1s | 1/s |
| Bronze | 1 | 1s | 1/s |
| Silver | 2 | 1s | 2/s |
| Gold and Platinum | 4 | 1s | 4/s |
Routes: No route uses it today.
catalogue
GET /plans only. One request is the whole price list computed for one buyer.
| Band | Limit | Window | Sustained |
|---|---|---|---|
| Sandbox | 60 | 60s | 1/s |
| Bronze | 60 | 60s | 1/s |
| Silver | 120 | 60s | 2/s |
| Gold and Platinum | 240 | 60s | 4/s |
Routes: GET /plans
The one route with no limit
GET /health is subject to nothing. It takes no key and no rate class, so a monitoring system can poll it as often as it likes.
Headers, and pacing yourself with them
Three headers are on every response in a rate-limited class, not only on 429s. A well-behaved client reads them continuously and never sees a 429 at all.
| Header | Meaning |
|---|---|
| X-RateLimit-Limit | How many requests the window permits. |
| X-RateLimit-Remaining | How many are left in it. Floors at 0. |
| X-RateLimit-Reset | Unix seconds at which the window ends. |
| Retry-After | Whole seconds, never 0. On a 429 only, and only on the window kind. |
Buckets are per key, not per account
Two keys means two independent buckets. That is the lever, not an accident: a partner running a nightly catalogue import on one key and live checkout on another has told us those are separate workloads and the import cannot starve the checkout. If you would rather they shared a bucket, use one key.
A bucket is keyed on the key and the rate class together, so listing the catalogue never eats the budget for placing orders.
The mechanism, and its honest cost
Fixed windows, not token buckets. The consequence is worth stating rather than discovering: a caller can spend a full window at the end of one and a full window at the start of the next, so the true worst case is 2× the limit over a moment.
That is acceptable because the rate limit is a fairness and cost control. It is not what keeps your spend inside your balance — a conditional debit does that — and it is not what prevents duplicate orders — a uniqueness constraint does that.
The other 429
Separately from all of the above, 30 failed authentications per 60 seconds per source address is a hard stop. It is checked before we look anything up and is consumed only by failures, so a working integration never touches it.
Read your own limits
GET /me returns rate_limits computed live from your band. It is the machine-readable version of this page and it moves with your tier without a redeploy on either side — so read it at startup rather than copying the table above into a constant.