Rate limits
Quotas, response headers, and a sensible backoff strategy
The Octopus Cards API enforces per-client rate limits to protect against abuse and ensure fair share for everyone. In normal integration traffic you should never hit them - but bulk campaigns, retry storms, or misbehaving scripts can. Be ready to handle 429 Too Many Requests gracefully.
Default quotas
Limits are set per client account and vary by endpoint class. Rough defaults for new accounts:
| Endpoint class | Sandbox (per minute) | Production (per minute) |
|---|---|---|
POST /auth/login | 30 | 10 |
POST /auth/refresh | 30 | 20 |
GET (reads, catalogue, wallets, orders) | 600 | 300 |
POST /api/v1/orders | 120 | 60 |
POST /api/v1/topups/orders | 120 | 60 |
POST /api/v1/esim/orders | 120 | 60 |
POST /api/v1/products/:id/charges | 300 | 120 |
POST /api/v1/topups/charges | 300 | 120 |
POST /api/v1/esim/charges | 300 | 120 |
Your specific limits may differ - check with your account manager, and ask for an uplift well in advance of high-traffic campaigns (we can lift them for known-good integrations).
Limits are averaged over rolling windows, so a short spike is fine. Sustained traffic above the quota will start getting 429s.
The 429 response
HTTP/1.1 429 Too Many Requests
Retry-After: 12
Content-Type: application/json
{
"error": {
"name": "TooManyRequestsError",
"code": "TOO_MANY_REQUESTS",
"message": "Rate limit exceeded. Retry after 12 seconds."
}
}The Retry-After header is the number of seconds you should wait. Use it - don't guess.
Backoff strategy
When you see 429:
- Pause the offending worker for
Retry-Afterseconds (or the value from the message if the header is missing). - Reduce concurrency if this is a bulk workflow.
- Retry the request.
- Only count as a "failure" after 3–5 consecutive
429s.
A minimal helper:
func doWithBackoff(req *http.Request, client *http.Client) (*http.Response, error) {
for attempt := 0; attempt < 5; attempt++ {
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != 429 {
return resp, nil
}
wait := parseRetryAfter(resp.Header.Get("Retry-After"))
if wait <= 0 {
wait = time.Duration(1<<attempt) * time.Second // 1, 2, 4, 8, 16
}
_ = resp.Body.Close()
time.Sleep(wait + jitter())
}
return nil, errors.New("rate-limited after 5 attempts")
}Avoiding 429 in the first place
- Batch where possible. One order with
quantity: 100is one API call. 100 orders ofquantity: 1is 100 API calls. - Cache reads. Countries, currencies, categories, subcategories, and individual product details change rarely. Cache for 5–15 minutes. The Charges endpoint even caches its own response for 5 minutes server-side - don't call it in a tight loop.
- Use webhooks instead of polling. For order status, subscribe to
order.completionrather than pollingGET /api/v1/orders/:id. - Throttle your own workers. A fixed concurrency limit (e.g. 10 in-flight order creations) is much safer than unbounded parallelism.
- Smooth out cron spikes. If you run a scheduled job that creates 10,000 orders at 00:00, stagger them over 5–10 minutes.
Bulk campaigns
If you're about to run a campaign that'll spike traffic by 10× or more:
- Tell us in advance. Your account manager can raise your per-minute quota for the campaign window.
- Use large-quantity orders. A single order of 5,000 vouchers costs one
POST, not 5,000. - Check wallet balance first. One call to the Charges endpoint is cheaper than hitting
Insufficient balance5,000 times. - Subscribe to
order.completion. Async processing + webhook is the only sane path at scale.
What isn't rate-limited
- JWT validity - an unexpired access token is always accepted; we don't throttle token validation.
- Webhook deliveries we send to you - those are our responsibility, not counted against your quota.
Summary
- Default quotas are generous enough for normal traffic. Campaigns should be flagged in advance.
- On
429, pause forRetry-Afterseconds, then retry. Don't guess the backoff. - Prefer batching, caching, and webhooks over repeated small reads/polls.
- Cap your own concurrency - it's the single biggest lever against accidental self-DoS.