This page documents how the Doxbrix API reports errors and how rate limiting works, so your integration can handle failures gracefully and back off correctly.
Error format
Errors return a consistent JSON object with a machine-readable error code, a human-readable message, and (where relevant) the offending field:
{
"error": "validation_error",
"message": "The 'title' field is required.",
"field": "title",
"requestId": "req_01J8X9Z3K2M4N5"
}Always log the requestId — include it when contacting support about a specific failed request.
HTTP status codes
| Status | Meaning |
|---|---|
| `200 OK` | Request succeeded |
| `201 Created` | Resource created |
| `204 No Content` | Succeeded with no body (e.g. delete) |
| `400 Bad Request` | Malformed request or validation error |
| `401 Unauthorized` | Missing or invalid API key |
| `403 Forbidden` | Authenticated, but not permitted (scope/project) |
| `404 Not Found` | Resource doesn't exist |
| `409 Conflict` | Conflicts with current state (e.g. duplicate slug) |
| `422 Unprocessable Entity` | Semantically invalid (e.g. publishing an empty page) |
| `429 Too Many Requests` | Rate limit exceeded |
| `5xx Server Error` | Something went wrong on our side |
Error codes
The error field is a stable string you can branch on:
| Code | Status | Meaning |
|---|---|---|
| `validation_error` | 400 | A field failed validation (see `field`) |
| `unauthorized` | 401 | No/invalid API key |
| `forbidden` | 403 | Key lacks the required scope or project access |
| `not_found` | 404 | Resource not found |
| `duplicate_slug` | 409 | A page/space with that slug already exists |
| `not_publishable` | 422 | Content failed publish validation |
| `rate_limited` | 429 | Too many requests |
| `internal_error` | 500 | Unexpected server error |
Handling errors
Branch on the error code, not the message (messages may change):
try {
await dx.pages.create({ title: '', spaceId: 'sp_01' })
} catch (e) {
if (e.error === 'validation_error') {
console.error(`Invalid field: ${e.field}`)
} else if (e.error === 'rate_limited') {
await sleep(e.retryAfter * 1000)
} else {
throw e
}
}Rate limits
API requests are rate-limited per API key. Limits depend on your plan:
| Plan | Requests / minute |
|---|---|
| Free | 60 |
| Pro | 600 |
| Business | 1,200 |
| Enterprise | Custom |
Rate limit headers
Every response includes your current rate-limit state:
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 583
X-RateLimit-Reset: 1700000000| Header | Meaning |
|---|---|
| `X-RateLimit-Limit` | Your per-minute ceiling |
| `X-RateLimit-Remaining` | Requests left in the window |
| `X-RateLimit-Reset` | Unix time when the window resets |
When you hit the limit
A 429 response includes a Retry-After header (seconds) telling you when to retry:
{
"error": "rate_limited",
"message": "Rate limit exceeded. Retry after 12 seconds.",
"retryAfter": 12
}Backing off
Implement exponential backoff with jitter on 429 and 5xx responses:
On 429, wait the number of seconds in Retry-After before retrying.
Retry transient 5xx errors with increasing delays (1s, 2s, 4s, …) plus jitter.
Give up after a few attempts and surface the error with its requestId.
Idempotency and conflicts
For POST requests, an Idempotency-Key header lets you retry safely after a network error without creating duplicates. A 409 duplicate_slug means the resource already exists — fetch it rather than retrying blindly.