Errors, limits & pagination
Predictable failures with machine-readable codes, generous limits with standard headers.
Errors
Following the GraphQL convention, a request that reached the API answers HTTP 200 and lists problems in errors. Branch on extensions.code, never on the message text.
{
"errors": [
{
"message": "this API key lacks the SETS_WRITE scope",
"path": ["editEmoteSet"],
"extensions": { "code": "missing_scope" }
}
],
"data": null
}| Code | Meaning |
|---|---|
unauthorized | The field needs an API key or session. |
missing_scope | The key lacks the scope named in the message. |
forbidden | Authenticated, but not allowed (not your emote, not an editor, staff only…). |
not_found | The target doesn't exist or isn't visible to you. |
rate_limited | Too many changes; retry later. |
invalid_name, invalid_alias, … | Input validation; the message explains the rule. |
internal | Our fault. Details are logged on our side and never leaked; retry with backoff. |
Single-object lookups like emote(id:) return null instead of an error when nothing is found. A malformed or
unknown query fails validation with a descriptive message and HTTP 422. Request bodies are capped at 1 MB.
HTTP-level failures carry a JSON body {"error":{"code","message"}}:
| Status | When |
|---|---|
401 | Invalid, expired or revoked API key (invalid_api_key). |
429 | Rate limit exceeded; see Retry-After. |
Rate limits
| Limit | Budget | Applies to |
|---|---|---|
| Per IP address | 600 requests / minute | Every request (IPv6 is grouped per /64) |
| Per API key | 1,200 requests / minute | All requests with that key, from any IP |
| Changes per account | 120 mutations / minute | Every mutation field, keys and sessions combined |
| Uploads | 20 / hour | Emote uploads per account |
Responses carry the IETF RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset headers (seconds
until the window resets), plus Retry-After on 429. Need more for a large integration? Batch: one editEmoteSet call applies up to 500 changes and counts as one mutation.
Query cost
Each query is priced before it runs: every field costs 1, and list fields multiply the cost of their children by the page size you ask
for. Queries above 5,000 are rejected with a COMPLEXITY_LIMIT_EXCEEDED validation error. In practice:
fetch what you display, and don't nest large lists inside large lists.
Pagination
Lists that can grow without bound use cursors. Pass first (1–100) and the previous page's endCursor as after until hasNextPage is false. Cursors are stable: new emotes don't shift pages you already fetched.
query Page($after: String) {
emotes(first: 100, after: $after) {
nodes { id name }
pageInfo { endCursor hasNextPage }
}
}let after = null;
do {
const { data } = await gql(PAGE_QUERY, { after });
handle(data.emotes.nodes);
after = data.emotes.pageInfo.hasNextPage ? data.emotes.pageInfo.endCursor : null;
} while (after);searchEmotes is ranked by relevance and therefore uses page numbers (page, perPage up to 100).
Its total is -1 while search runs in degraded mode and the total is unknown.