Skip to main content

API error handling by example

Every Orbit API error returns the same envelope: a machine-readable code, a human-readable message, the HTTP status, and contextual details, plus a meta.docs_url that links straight to the error’s remedy. This guide walks that envelope, works through three real failures end to end, and gives you a retriable-vs-terminal decision table so your integration handles errors the same way every time. For the full code list, see the Error Code Reference. For codes by domain with remediation notes, see Error Codes.

1. The error envelope

A failed request returns the same JSON shape regardless of which endpoint rejected it:
Read it in this order: Two shape notes that matter in code:
  • details is optional. Legacy and triage envelopes may omit it — always guard details?.field rather than assuming the key exists.
  • 429 bodies are self-describing. A rate-limited response carries the back-off hint inside the envelope (error.retry_after on the global limiter, details.retry_after_seconds on caps) and as a Retry-After header — read whichever your HTTP client exposes.

2. Three worked failures with the fix in code

(a) INVALID_PHONE_NUMBER — a non-E.164 to on an SMS send

Sending to a number that isn’t E.164 (a bare digit string, a local-format number) is rejected with HTTP 422 before anything dispatches:
This is a terminal, caller-fixable error — retrying the same body returns the same 422. The fix is to normalize the destination into E.164 (+{country-code}{national-number}, no spaces or dashes) and resend:
In code, treat INVALID_PHONE_NUMBER as a validation failure to correct, not a send failure to retry:
The same handling covers INVALID_FROM_NUMBER (the from side) and INVALID_RECIPIENT — all three mean “the number shape is wrong for this send,” never a transient provider hiccup.

(b) RATE_LIMITED 429 — read Retry-After, back off exponentially

When your request rate exceeds the global limiter, Orbit answers with HTTP 429 and a body that tells you exactly how long to wait:
The response also carries a Retry-After: 4 header. Honor it before any exponential fallback — the server-set value reflects the actual bucket refill time, so a blind doubling may retry too soon (and re-429). Node retry wrapper:
Python retry wrapper:
Prefer the server’s retry_after over your own doubling whenever it’s present — the limiter knows when its window opens; your client doesn’t.

(c) FREQUENCY_CAP_EXCEEDED — skip the recipient, don’t fail the batch

A frequency cap you configured (Settings → Frequency caps, or the Frequency Caps API) rejects an over-cap direct send with HTTP 429 and a details block naming the exact rule that fired:
This is skip-not-fail: the recipient is over their allowance, not erroring. The correct handling is to defer that one recipient and move on:
Two rules that keep this cheap:
  • Never re-POST immediately. A capped recipient 429s again until retry_after_seconds elapses — an immediate retry just burns an API call. Wait the server-provided retry_after_seconds, not a fixed backoff: the oldest in-window send ages out then, and the slot reopens.
  • Batch sends skip by design. Campaign and drip sends don’t 429 per recipient — the capped recipient reports status: "skipped", reason: "frequency_capped" (with the same frequency_cap_id and retry_after_seconds fields) and the batch moves on. Handle the skip status in your result reconciliation rather than trying to pre-filter caps yourself.
Distinguish this from a delivery failure: the carrier never saw the message, no slot was consumed, and nothing about the recipient’s record needs repair — the send simply has to wait for a slot in the window.

3. Retriable vs terminal

Classify every error you handle into one of three buckets — this decision determines whether you retry, defer, or surface the failure to the caller: The decision tree:
  1. Is error.code in the permanent set you know are yours? (INVALID_PHONE_NUMBER, NO_SENDER_CONFIGURED, INSUFFICIENT_BALANCE, RECIPIENT_OPTED_OUT, …) → Terminal. Fix the input or surface to the caller; never retry the same body.
  2. Is the status 429?Deferred. Read retry_after / retry_after_seconds and re-queue the send after that delay. FREQUENCY_CAP_EXCEEDED is a per-recipient skip; RATE_LIMITED is a global back-off.
  3. Is the status 5xx?Retriable. INTERNAL_ERROR and SERVICE_UNAVAILABLE represent platform-side conditions that resolve; retry with exponential backoff and a capped attempt count. If the original request was a send, reuse the same idempotency key so a retry can’t double-send.
  4. Unlisted error.code on a 5xx? Treat it as retriable until the reference says otherwise — the platform returns 5xx only for server-side conditions, and preferring a cautious retry over dropping a recipient is the safe default.

4. From error to remedy via meta.docs_url

Every envelope carries meta.docs_url, a per-code link into the Error Code Reference:
Use it two ways:
  • In developer tooling. Log or surface docs_url alongside the error so the person debugging lands directly on the code’s anchor instead of searching the reference.
  • In your own error dictionary. Map the codes you handle to the remediation you apply (INVALID_PHONE_NUMBER → normalize E.164; FREQUENCY_CAP_EXCEEDED → defer recipient), and point the mapped entry at the same docs anchor for the human follow-up.
The full registry with remediation notes per code is the single source of truth — reach for it before inventing handling for a code you haven’t seen:

See also