GuidesBest Practices

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 classSandbox (per minute)Production (per minute)
POST /auth/login3010
POST /auth/refresh3020
GET (reads, catalogue, wallets, orders)600300
POST /api/v1/orders12060
POST /api/v1/topups/orders12060
POST /api/v1/esim/orders12060
POST /api/v1/products/:id/charges300120
POST /api/v1/topups/charges300120
POST /api/v1/esim/charges300120

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:

  1. Pause the offending worker for Retry-After seconds (or the value from the message if the header is missing).
  2. Reduce concurrency if this is a bulk workflow.
  3. Retry the request.
  4. 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: 100 is one API call. 100 orders of quantity: 1 is 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.completion rather than polling GET /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:

  1. Tell us in advance. Your account manager can raise your per-minute quota for the campaign window.
  2. Use large-quantity orders. A single order of 5,000 vouchers costs one POST, not 5,000.
  3. Check wallet balance first. One call to the Charges endpoint is cheaper than hitting Insufficient balance 5,000 times.
  4. 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 for Retry-After seconds, 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.

On this page