Getting started

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 403 on 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-Key you sent originally, so the retry can't double-act. This is the whole point of idempotency: a 429, a timeout, or a 5xx is 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 401 vs 403 means.
  • Idempotency — why every retry carries the same key.
  • Versioning — how the error contract evolves without breaking you.