Conventions
Errors
Errors use HTTP status codes and one JSON shape everywhere, with a stable code to branch on and a request_id to quote when something looks wrong.
{
"error": {
"code": "validation_failed",
"message": "Some fields are invalid.",
"details": {
"email": [
"Must be a valid email address."
],
"first_name": [
"Required."
]
},
"request_id": "5f0c2e7a-9b4d-4a1c-8e3f-6d2b9a7c1e05"
}
}- codestring
Stable and machine-readable — branch on this. The codes are listed below.
- messagestring
For people, in English. It may change; do not parse it.
Extra data for some codes: the invalid fields for
validation_failed,retry_afterforrate_limited,hubandupgrade_urlforhub_not_in_plan.- request_idstring
The ID of this request, also in the
X-Request-Idheader.
| Status | Meaning |
|---|---|
| 200 OK | The request succeeded. Reads, updates, deletes and restores return 200. |
| 201 Created | A POST created a record. The body holds the new record. |
| 400 Bad Request | validation_failed or invalid_json. |
| 401 Unauthorized | unauthorized: no valid API key. |
| 403 Forbidden | insufficient_scope, forbidden, hub_not_in_plan or workspace_read_only. |
| 404 Not Found | not_found: unknown, or not visible to the key’s owner. |
| 409 Conflict | idempotency_key_reused. |
| 429 Too Many Requests | rate_limited. Wait for Retry-After seconds. |
| 500 Internal Server Error | internal_error. Retry with backoff. |
| 503 Service Unavailable | The workspace is briefly unavailable, for example during an update. The body may not be JSON. Retry with backoff. |
A query parameter or body field is invalid: a wrong type, a value out of range, an unknown filter or sort field.
detailsmaps each field to its messages.What to doFix the fields listed in
details. Retrying the same request returns the same error.invalid_json400The request body is not valid JSON.
What to doSend a JSON object with
Content-Type: application/json.The key is valid but does not carry the scope this endpoint requires.
What to doUse a key with the scope shown on the endpoint, for example
deals:write.forbidden403The key’s owner is not allowed to do this: their role in the workspace does not permit the action.
What to doUse a key owned by someone with the permission, or change the owner’s role.
The endpoint belongs to a hub the workspace’s plan does not include.
details.hubnames the hub anddetails.upgrade_urllinks to the plan.What to doAdd the hub to the plan, then retry.
The workspace is read-only, for example after its subscription ended. Reads still work; writes are refused, except creating leads.
What to doRestore the subscription. Writes succeed again as soon as it is active.
not_found404The path is unknown, or the record does not exist or is not visible to the key’s owner. The API does not tell these apart.
What to doCheck the ID, and that the key’s owner can see the record in the app.
The
Idempotency-Keywas used in the last 24 hours with a different request body.What to doGenerate a new key for every new request; reuse a key only to retry the same request.
rate_limited429The key sent more requests than its limit allows.
details.retry_afterand theRetry-Afterheader give the seconds to wait.What to doWait
Retry-Afterseconds, then retry. Spread bulk work over time.Something failed on our side. The request may or may not have been applied.
What to doRetry with exponential backoff, with an
Idempotency-Keyfor POST. Send therequest_idto support if it persists.
New codes may be added; treat an unknown code by its HTTP status.
validation_failed lists every problem at once, so a form or an import can show them all. details maps a field — or a query parameter, such as sort or a filter — to its messages.
Fields the API does not know are ignored rather than refused, so a typo in a field name passes silently. Check the response: it holds what was stored.
Retry what can succeed later — 429, 500 and 503 — with growing delays, and fix the request for anything else. Give POST requests an Idempotency-Key so a retry can never create a record twice.
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
async function nextora(path, init = {}, attempt = 1) {
const response = await fetch(`https://acme.nextora.co/api/v1${path}`, {
...init,
headers: {
Authorization: `Bearer ${process.env.NEXTORA_API_KEY}`,
'Content-Type': 'application/json',
...init.headers,
},
});
if (response.ok) return response.json();
if (RETRYABLE.has(response.status) && attempt < 5) {
const retryAfter = Number(response.headers.get('Retry-After'));
const delay = retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 250 + Math.random() * 250;
await new Promise(resolve => setTimeout(resolve, delay));
return nextora(path, init, attempt + 1);
}
const body = await response.json().catch(() => null);
const error = new Error(body?.error?.message || `HTTP ${response.status}`);
Object.assign(error, { status: response.status, code: body?.error?.code, details: body?.error?.details, requestId: body?.error?.request_id });
throw error;
}Every response has an X-Request-Id header, and every error repeats it as request_id. Log it with failures; when you write to [email protected], the request ID lets us find the exact request.