docs / api / api-errors-and-rate-limits
API

API errors and rate limits

The full /api/v1 error code table, rate-limit headers, and how to back off correctly.

DX
Developer Experience Team
Updated Aug 2, 2026 · 6 min read

Every failure from /api/v1 is the same JSON shape with a stable machine-readable code. Branch on code, not on the message text or the HTTP status alone — messages get reworded, codes never change meaning.

error.json
{
  "error": {
    "code": "not_entitled",
    "message": "This isn't part of your purchases. Buy the pack or lifetime access to download it.",
    "request_id": "5f3c9a10-2d6b-4b2e-9f77-1c0a8de41b52"
  }
}

request_id is always populated, and always equals the x-request-id header on the same response — so you can log it from the headers of successful calls too. If you send your own x-request-id (8–64 characters, letters, digits, _ and -), we use yours instead of generating one. Log it either way: quoting it in a support email points us straight at the call that failed.

Error codes

CodeHTTPWhat it meansWhat to do
missing_api_key401No key on the request.Send Authorization: Bearer tja_live_… or X-API-Key.
invalid_api_key401The key is malformed, unknown, or the wrong kind of credential.Check it was copied in full. If it starts with TJA_, you sent a license key — see API authentication and keys.
revoked_api_key401The key was revoked.Create a new one in Connections.
expired_api_key401The key passed its expiry date.Create a new one; consider "never expires" for keys you control.
api_disabled403API access is not available for this account right now. It is also what the public /v1/catalog/* routes answer while the API is switched off.Check the Connections tab, which explains the current state. Your keys are untouched.
not_eligible403The account has no purchase.Buy any pack or lifetime access.
blocked403The request was refused before anything else was checked; the message is just Access denied.Nothing to fix in your code. If you believe it is a mistake, contact support with the request_id.
insufficient_scope403The key lacks the scope this endpoint needs.Create a key with downloads:read (or catalog:read) enabled.
not_entitled403The item is real but not part of your purchases.Buy the pack or lifetime access.
not_found404No such published pack or asset.Check the slug. Coming-soon and archived items are 404s on purpose.
file_unavailable404The item exists but the requested file is not there — often an edition=authoring a pack does not have.Retry shortly, or drop the edition parameter.
range_not_satisfiable416The Range you asked for starts past the end of the file.Read Content-Range: bytes */<size> and correct your offset.
rate_limited429Too many requests.Wait for Retry-After, then retry.
internal_error500Something broke on our side.Retry with backoff. If it persists, send us the request_id.

invalid_api_key deliberately does not distinguish "malformed" from "no such key", api_disabled does not say which switch produced it, and blocked says nothing at all. That is a security choice, not an omission.

Rate limits

Limits are per key for authenticated calls, so one busy script of yours cannot starve another — and a colleague behind the same office IP never pays for your loop. The public catalog is limited per IP address instead, since there is no key to attribute it to.

TrafficDefaultWindowKeyed by
Metadata reads (/v1/me, /v1/packs, /v1/assets, /v1/orders)120 requests1 minuteyour API key
Downloads (both /download endpoints)30 requests1 minuteyour API key
Public catalog (/v1/catalog/*)120 requests1 minuteIP address
Failed authentication attempts20 attempts1 minuteIP address

Reads and downloads have separate budgets, so listing your library never eats into your download allowance. Downloads are the tight one — each streams a multi-megabyte file — so pull sequentially rather than in parallel.

Rate-limit headers

Every response that reached the rate limiter carries them — successes, 404s, 403 not_entitled, and the 429 itself — so a well-behaved client can pace itself long before it sees a 429. The exceptions are the refusals decided before the limiter runs: blocked, a 500 internal_error, and (on the authenticated routes) api_disabled and not_eligible come back without them. Both spellings go out; use whichever your HTTP client already parses.

headers
RateLimit-Policy: "download";q=30;w=60
RateLimit: "download";r=27;t=41
X-RateLimit-Limit: 30
X-RateLimit-Remaining: 27
X-RateLimit-Reset: 41
HeaderMeaning
RateLimit-PolicyThe policy name, its quota (q) and window in seconds (w).
RateLimitThe policy name, requests remaining (r) and seconds until reset (t).
X-RateLimit-LimitSame as q.
X-RateLimit-RemainingSame as r.
X-RateLimit-ResetSame as tseconds from now, not a timestamp.

Policy names you will see: read, download, public, auth.

A 429 additionally carries Retry-After, in seconds:

throttled
HTTP/1.1 429 Too Many Requests
Retry-After: 41

Backing off politely

retry.sh
fetch() {
  for attempt in 1 2 3 4 5; do
    code=$(curl -s -o body.json -w '%{http_code}' \
      -D headers.txt -H "Authorization: Bearer $TJA_API_KEY" "$1")
    [ "$code" = "429" ] || { cat body.json; return 0; }
    wait=$(awk 'tolower($1) == "retry-after:" { print $2 }' headers.txt | tr -d '\r')
    sleep "${wait:-5}"
  done
  echo "gave up on $1" >&2
  return 1
}

Two rules that keep you comfortably inside the limits:

  • Honour Retry-After. It is exact; guessing is not.
  • Watch X-RateLimit-Remaining and slow down before it reaches zero, rather than sprinting into a 429 and recovering.

Repeated failed authentication is limited separately and by IP. If you are looping on invalid_api_key, fix the key rather than retrying — you will hit the failed-auth limit and then get 429s that look unrelated.

Troubleshooting

  • 401 on every call — check the header name and that the key was copied whole, including the trailing checksum. Try /v1/me first; it is the cheapest test.
  • 403 insufficient_scope on downloads only — the key was created without downloads:read. Scopes cannot be edited; create a new key.
  • 404 on a pack you own — coming-soon packs are not downloadable yet, and archived ones are gone. Confirm the slug against /v1/packs.
  • A download that redirects to a sign-in page — you fetched preview_url (the public preview GLB) instead of download_url, without a key. See API endpoint reference.
  • amount is null in /v1/orders — an older order never recorded a total. That means unknown, not free.

Next steps

API errors and rate limits — threejsassets.com