Rate limits and plans
Every account has a plan. Headers on each response tell you where you stand.
Free
$0forever
For trying the API and small personal projects.
- 20 requests per hour
- Every endpoint, including all history
- Up to 5 API keys
- Live player lookups
Pro
Unlimited$8per month
For Discord apps, websites and dashboards that run all day.
- Unlimited requests
- Bursts up to 300 requests per minute
- Every endpoint, including all history
- Up to 5 API keys
- Cancel any time from your dashboard
Both plans can call every endpoint and read all history. The only difference is how often. Upgrade, change your card or cancel from your dashboard. Payments are handled by Stripe.
Limits belong to the account, so every key on an account shares them. Every request made with a valid key counts, including 304 Not Modified answers and errors such as 400 and 404.
The burst limit on Pro keeps the API fast for everyone. A client that spreads its calls out never notices it.
Responses look like this. The values are an example.
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
cache-control: private, max-age=20, stale-while-revalidate=120
x-ratelimit-limit: 20
x-ratelimit-remaining: 17
x-ratelimit-reset: 1840Past the limit the API answers 429 Too Many Requests with a retry-after header and the same number in the body as retryAfter:
HTTP/1.1 429 Too Many Requests
retry-after: 1840
x-ratelimit-limit: 20
x-ratelimit-remaining: 0
x-ratelimit-reset: 1840
{
"error": "Hourly limit reached. The Free plan allows 20 requests per hour",
"retryAfter": 1840
}On Pro the wait is a second or two, so retrying is the right move. On Free it can be most of an hour, so give up and show a message instead. This helper does both:
async function donutstats(path, attempts = 3) {
for (let attempt = 1; ; attempt++) {
const response = await fetch(`https://api.donutstats.io${path}`, {
headers: { "x-api-key": process.env.DONUTSTATS_API_KEY },
});
const wait = Number(response.headers.get("retry-after") ?? 1);
if (response.status !== 429 || attempt === attempts || wait > 60) {
if (!response.ok) throw new Error(`DonutStats API returned ${response.status}`);
return response.json();
}
await new Promise((resolve) => setTimeout(resolve, wait * 1000));
}
}
const orders = await donutstats("/v1/orders?sort=value&limit=50");Live player refreshes are expensive, so they have their own per minute budget on top of the request limit. Past it, the player endpoint returns stored data with lookup.status set to rate_limited, or a 429 if there is nothing stored. Read live lookups for the details.
The image endpoints need no key and do not count towards your plan. They are limited to 60 images per minute per IP address instead.
- Cache responses for as long as their
cache-controlheader allows. Data does not change faster than that. See caching and freshness. - Prefer list endpoints over many single calls.
GET /v1/itemsreturns prices, changes and a 30 day sparkline for 200 products in one request. - Use big pages for history. History endpoints return up to 5,000 points per request.
- Use
lookup=nonewhen you read players in bulk. - Spread scheduled jobs out instead of firing them all at the top of the hour.
Poll summaries, not lists
GET /v1/auction/summary and GET /v1/orders/summary are tiny and carry updatedAt. Poll those and only fetch the big lists when they change.
