Errors & rate limits
The JSON error envelope, HTTP status codes, request ids, rate limits, and how to handle them safely.
Every error is a JSON envelope with a stable code you can branch on and a request_id you can quote in support. Nothing in an error body is PHI — it's ids, codes, and human-readable messages.
The error envelope#
Failed requests return a non-2xx status and a body shaped like this:
{
"error": {
"type": "permission_error",
"code": "order_not_approved",
"message": "Refusing to submit: order has not been approved by a licensed provider.",
"request_id": "req_8f2c1a90"
}
}
| Field | What it is |
|---|---|
type |
The broad category — a closed enum (authentication_error, permission_error, not_found, conflict, rate_limit_error, validation_error, service_unavailable, api_error). Branch on this for coarse handling. |
code |
A stable, specific machine code (e.g. order_not_approved, invalid_request, rate_limited). Branch on this for precise handling. |
message |
Human-readable, safe to log. Never contains PHI or stack traces. |
param |
Present on some validation errors — the offending field. |
request_id |
The id of the request that failed. Quote it in support. Also returned as the X-Request-Id header. |
Treat type and code as the contract — the wording of message may change. Never parse message.
Request ids#
Every response — success or failure — carries an X-Request-Id header (req_…), echoed as request_id inside error bodies. Log it on every call. It's the fastest way for us to trace exactly what happened on a given request.
curl -i https://api.neolife.health/v1/orders/ord_123 \
-H "Authorization: Bearer $NEOLIFE_API_KEY"
# ...
# X-Request-Id: req_8f2c1a90
HTTP status codes#
| Status | type |
Means here |
|---|---|---|
400 |
validation_error |
Malformed request — bad JSON, missing required field, wrong type. |
401 |
authentication_error |
Missing, malformed, or invalid API key. See Authentication. |
403 |
permission_error |
Authenticated, but not allowed — your key lacks the required scope, or the action is clinically blocked (e.g. submitting an order no licensed provider has approved). |
404 |
not_found |
No such resource on your tenant. A sandbox key never sees live resources, and vice-versa. |
409 |
conflict |
A state conflict — most often an idempotency collision (same Idempotency-Key, different body), or an order already in a terminal state. |
422 |
validation_error |
Well-formed but semantically invalid — a value the API can parse but can't accept. |
429 |
rate_limit_error |
Rate limited. Back off and retry (see below). |
5xx |
api_error / service_unavailable |
Something failed on our side. Retryable — safe mutations are idempotent, so retry with the same key. |
A
403on submit almost always means the order isn't provider-approved. A licensed provider approves every clinical order before it can be submitted — the API refuses to submit an unapproved one. Check the order's status and route it through intake approval first.
Rate limits#
The API is rate limited per API key. When you exceed your limit you get a 429 with type: rate_limit_error. On a 429:
- Back off before retrying — use exponential backoff with jitter.
- Retry with the same
Idempotency-Keyyou sent originally, so the retry can't double-act. This is the whole point of idempotency: a429, a timeout, or a5xxis always safe to retry when the key is stable.
Sandbox and live keys are limited independently, so load-testing against a nk_sandbox_… key won't eat your live budget.
Handling errors#
Retry 429 and 5xx with backoff and the same key; treat 4xx (other than 429) as terminal — fix the request, don't retry it blindly.
async function submitWithRetry(orderId: string, key: string) {
for (let attempt = 0; attempt < 5; attempt++) {
const res = await fetch(
`https://api.neolife.health/v1/orders/${orderId}/submit`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.NEOLIFE_API_KEY}`,
"Idempotency-Key": key, // stable across every retry
},
},
);
if (res.ok) return res.json();
const body = await res.json();
const { type, code, request_id } = body.error;
// Retryable: rate limit or a transient server error.
if (res.status === 429 || res.status >= 500) {
const wait = Math.min(2 ** attempt * 250, 8000) + Math.random() * 250;
await new Promise((r) => setTimeout(r, wait));
continue;
}
// Everything else is terminal — surface it.
throw new Error(`${code} (${type}) [${request_id}]`);
}
throw new Error("exhausted retries");
}
The same shape in bash — retry on 429/5xx, reuse the key:
KEY="3f1b0c2a-9d6e-4a51-8b2f-1c7e5a9d0e42"
for attempt in 1 2 3 4 5; do
code=$(curl -s -o /tmp/body.json -w "%{http_code}" \
-X POST "https://api.neolife.health/v1/orders/ord_123/submit" \
-H "Authorization: Bearer $NEOLIFE_API_KEY" \
-H "Idempotency-Key: $KEY")
if [ "$code" -lt 300 ]; then break; fi
if [ "$code" = 429 ] || [ "$code" -ge 500 ]; then
sleep $((2 ** attempt)); continue # back off, retry with the SAME key
fi
jq '.error' /tmp/body.json # 4xx — terminal, inspect and fix
break
done
The SDKs do this for you: they parse the envelope into typed errors, retry 429/5xx with backoff, and reuse the idempotency key automatically.
Next steps#
- Authentication — keys, scopes, and what a
401vs403means. - Idempotency — why every retry carries the same key.
- Versioning — how the error contract evolves without breaking you.