> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# API error handling by example

> Handle Orbit API failures in code — read the error envelope, fix validation errors, back off on 429 rate limits, and treat frequency-cap rejections as skips, not failures.

# 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](/reference/error-codes). For codes by domain with remediation notes, see [Error Codes](/api-reference/error-codes).

## 1. The error envelope

A failed request returns the same JSON shape regardless of which endpoint rejected it:

```json theme={null}
{
  "error": {
    "code": "INVALID_PHONE_NUMBER",
    "message": "The 'to' field must be a valid E.164 phone number",
    "status": 422,
    "details": {
      "field": "to",
      "value": "+1234",
      "expected": "E.164 format e.g. +14155552671"
    }
  },
  "meta": {
    "request_id": "req_abc123",
    "timestamp": "2026-03-08T00:00:00Z",
    "docs_url": "https://docs.orbit.devotel.io/errors/INVALID_PHONE_NUMBER"
  }
}
```

Read it in this order:

| Field             | What it tells you                                                                            | What to do with it                                                                                                                  |
| ----------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `error.code`      | The stable, retry/consume decision key (e.g. `RATE_LIMITED`, `FREQUENCY_CAP_EXCEEDED`).      | Branch on this. Never parse `message` — wording can change; the code does not.                                                      |
| `error.message`   | A human-readable explanation of this rejection.                                              | Log it; show it to operators, not to end users.                                                                                     |
| `error.status`    | The HTTP status as an integer (422, 429, 503, …).                                            | Use it for coarse routing when no finer branch applies.                                                                             |
| `error.details`   | Structured context for the failure — which field failed, which rule fired, how long to wait. | Read the sub-field that names the fix: `details.field` for validation errors, `details.retry_after_seconds` for caps and throttles. |
| `meta.request_id` | The server's correlation id for this request.                                                | Include it when you open a support case — it ties to the platform's server-side log.                                                |
| `meta.docs_url`   | A docs link specific to this error code.                                                     | Surface it in developer tooling; it resolves to the code's anchor on the reference page.                                            |

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:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/messages \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "sms",
    "to": "+1234",
    "body": "Your order shipped."
  }'
```

```json theme={null}
{
  "error": {
    "code": "INVALID_PHONE_NUMBER",
    "message": "The 'to' field must be a valid E.164 phone number",
    "status": 422,
    "details": {
      "field": "to",
      "value": "+1234",
      "expected": "E.164 format e.g. +14155552671"
    }
  }
}
```

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:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/messages \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "channel": "sms",
    "to": "+14155552671",
    "body": "Your order shipped."
  }'
```

In code, treat `INVALID_PHONE_NUMBER` as a validation failure to correct, not a send failure to retry:

```typescript theme={null}
if (err.code === "INVALID_PHONE_NUMBER") {
  // Terminal: fix the input, don't retry the same payload.
  markRecipientInvalid(err.details?.field, err.details?.value);
  return { sent: false, reason: "invalid_number" };
}
```

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:

```json theme={null}
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Rate limit exceeded. Retry after 4 seconds.",
    "status": 429,
    "retry_after": 4
  },
  "meta": { "request_id": "req_xyz789", "timestamp": "2026-03-08T00:01:12Z", "docs_url": "https://docs.orbit.devotel.io/errors/RATE_LIMITED" }
}
```

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:

```typescript theme={null}
async function sendWithRetry(
  fn: () => Promise<Response>,
  maxAttempts = 5,
): Promise<Response> {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const res = await fn();
    if (res.status !== 429) return res;

    const body = await res.clone().json().catch(() => ({}));
    const retryAfterSeconds =
      Number(res.headers.get("Retry-After")) ||
      Number(body?.error?.retry_after) ||
      Math.min(2 ** (attempt - 1), 32); // capped exponential fallback: 1s, 2s, 4s, …

    if (attempt === maxAttempts) return res;
    await new Promise((r) => setTimeout(r, retryAfterSeconds * 1000));
  }
  throw new Error("unreachable");
}
```

Python retry wrapper:

```python theme={null}
import time

def send_with_retry(fn, max_attempts=5):
    for attempt in range(1, max_attempts + 1):
        res = fn()
        if res.status_code != 429:
            return res

        retry_after = res.headers.get("Retry-After")
        if retry_after is None:
            try:
                retry_after = res.json()["error"]["retry_after"]
            except (ValueError, KeyError):
                retry_after = min(2 ** (attempt - 1), 32)  # capped exponential fallback

        if attempt == max_attempts:
            return res
        time.sleep(float(retry_after))
    raise RuntimeError("unreachable")
```

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](/api-reference/frequency-caps)) rejects an over-cap direct send with HTTP 429 and a `details` block naming the exact rule that fired:

```json theme={null}
{
  "error": {
    "code": "FREQUENCY_CAP_EXCEEDED",
    "message": "Send blocked by a frequency-cap rule for this recipient and channel. Try again later or raise the cap in Settings → Frequency caps.",
    "status": 429,
    "details": {
      "channel": "sms",
      "window_seconds": 86400,
      "max_count": 3,
      "cap_id": "fc_9Qx2...",
      "retry_after_seconds": 4180
    }
  }
}
```

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:

```typescript theme={null}
if (err.code === "FREQUENCY_CAP_EXCEEDED") {
  const deferSeconds = err.details?.retry_after_seconds ?? 3600;
  deferRecipient(recipient, deferSeconds); // re-queue after the window, don't drop
  continue; // next recipient — do not abort the batch
}
```

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:

| Class                                             | Examples                                                                                         | Retry?                                       | Handling                                                                       |
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------------- | ------------------------------------------------------------------------------ |
| **Validation (4xx, your input is wrong)**         | `INVALID_PHONE_NUMBER`, `INVALID_FROM_NUMBER`, `VALIDATION_ERROR`, `MISSING_REQUIRED_FIELD`      | No — same input, same 422                    | Correct the payload per `details.field` / `details.expected`, then resend      |
| **Throttle / cap (429, wait then retry)**         | `RATE_LIMITED`, `RATE_LIMIT_EXCEEDED`, `FREQUENCY_CAP_EXCEEDED`, `VERIFY_RECIPIENT_RATE_LIMITED` | Yes — after the server-provided wait         | Honor `retry_after` / `retry_after_seconds` / the `Retry-After` header         |
| **Transient (5xx, platform-side, safe to retry)** | `INTERNAL_ERROR`, `SERVICE_UNAVAILABLE`, `BALANCE_SERVICE_UNAVAILABLE`, `SOFTSWITCH_UNHEALTHY`   | Yes — with backoff and a bounded retry count | Exponential backoff, bounded attempts, idempotency key on the original request |

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](/reference/error-codes):

```json theme={null}
"meta": {
  "request_id": "req_abc123",
  "docs_url": "https://docs.orbit.devotel.io/errors/INVALID_PHONE_NUMBER"
}
```

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:

* [Error Code Reference](/reference/error-codes) — every registered code, with HTTP status and cause.
* [Error Codes](/api-reference/error-codes) — the common codes, pinned with the envelope shape.

## See also

* [Frequency caps](/guides/frequency-caps) — the rules behind `FREQUENCY_CAP_EXCEEDED`
* [API integration](/guides/api-integration) — base URLs, sandbox, and the rate-limit headers
* [Pagination](/guides/pagination) — `INVALID_CURSOR` handling for list endpoints
