Rate limits
Each key has two limits. Calls a minute smooth out bursts, so one loop gone wrong cannot crowd out everyone else. Calls a day are the allowance of your plan. Both count per key, never per address, so the same key used from several servers shares one allowance. The day resets at 00:00 UTC.
| Plan | Price | Calls a minute | Calls a day | Endpoints |
|---|---|---|---|---|
| Free key | Free | 10 | 50 | 9 |
| Archive | $19 a month | 60 | 5,000 | 38 |
| Odds | $49 a month | 120 | 20,000 | 43 |
| Live | $149 a month | 300 | 100,000 | 45 |
The headers on every answer
Every answer, errors included, says where you stand, so you never have to guess or keep your own count.
| Header | What it says |
|---|---|
X-RateLimit-Limit | Calls allowed a minute on your plan |
X-RateLimit-Remaining | Calls left this minute |
X-RateLimit-Reset | When the minute resets, as a Unix time |
X-Quota-Remaining | Calls left today |
Retry-After | Seconds to wait, on a 429 only |
curl -s -D - -o /dev/null https://api.badmintonapi.com/v1/results/yesterday \
-H "Authorization: Bearer $BADMINTON_API_KEY" | grep -i -E 'ratelimit|quota'
X-RateLimit-Limit: 10
X-RateLimit-Reset: 1790719740
X-RateLimit-Remaining: 9
X-Quota-Remaining: 49When you reach a limit
You get a 429. Its error.code says which limit: rate_limited means too many calls this minute, and a few seconds' wait is enough; quota_exceeded means today's calls are used, and error.resets_at says when they return. Either way Retry-After gives the seconds to wait. A refused call is never counted, so waiting and trying again costs nothing.
import time, requests
def get(path, **params):
for attempt in range(5):
r = requests.get(BASE + path, params=params, headers=AUTH)
if r.status_code != 429:
return r.json()
if r.json()["error"]["code"] == "quota_exceeded":
raise RuntimeError("today's calls are used")
time.sleep(int(r.headers.get("Retry-After", 5)))
raise RuntimeError("still limited after 5 tries")async function get(path) {
for (let i = 0; i < 5; i++) {
const r = await fetch(BASE + path, { headers: AUTH });
if (r.status !== 429) return r.json();
const wait = Number(r.headers.get("Retry-After") || 5);
await new Promise(ok => setTimeout(ok, wait * 1000));
}
throw new Error("still limited after 5 tries");
}Staying well inside them
Most of the archive does not change: a finished draw, a player's past matches or last season's calendar will read the same tomorrow, so keep them and ask again only for what moves. Walk long lists with per_page=200 and the cursor, one call where the default page size would take four. For live scores on the Live plan, use the stream or webhooks rather than calling /v1/live in a tight loop. GET /v1/usage shows today's calls and your limits, and is never counted.
Several servers, one key
Because limits count per key, ten workers sharing one key share one minute's allowance. Either give each worker a slice (a Live key allows 300 calls a minute, so ten workers can make 30 each), or put the calls through one queue that reads X-RateLimit-Remaining and waits when it reaches zero. A burst that runs into the minute limit is not harmful: the refused calls are not counted, and waiting for Retry-After puts you straight back on track.