Errors
Every non-2xx response from /api/v1 endpoints carries the same JSON body:
{
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded"
}
}
code is a stable machine-readable identifier — branch on it, not on the
message. message is human-readable detail; for validation failures it names
the offending fields.
Error codes
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Malformed body or parameters — the message lists what's wrong. |
| 401 | unauthorized | Missing, malformed, revoked, or expired API key. |
| 403 | forbidden | Valid key, but it lacks the scope — or its owner's team role lacks the permission. |
| 404 | not_found | The file doesn't exist (or belongs to another team). |
| 413 | payload_too_large | Direct upload over 100 MB — use the resumable tus endpoint. |
| 415 | unsupported_media_type | Content type not accepted (images, videos, PDF, plain text only). |
| 429 | rate_limited | The key's request budget is exhausted — wait Retry-After seconds. |
| 507 | quota_exceeded | The team's storage quota is full. |
| 500 | internal_error | Unexpected server error — safe to retry with backoff. |
Handling rate limits
429 responses include a Retry-After header with the number of seconds
until the window resets. Respect it rather than hammering:
async function apiFetch(url, options) {
for (;;) {
const res = await fetch(url, options)
if (res.status !== 429) return res
const wait = Number(res.headers.get("retry-after") ?? "5")
await new Promise((resolve) => setTimeout(resolve, wait * 1000))
}
}
Validation errors
400 invalid_request messages name each invalid field and what was expected,
so they're safe to log during development:
{
"error": {
"code": "invalid_request",
"message": "limit: Too big: expected number to be <=100"
}
}