> ## 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.

# Error-handling runbook

> Classify any Orbit API error code and pick the right remediation — retry with backoff, fix the request, or escalate — plus where errors live when the request returned 200.

# Error-handling runbook

The [error-code reference](/reference/error-codes) lists every code the platform can emit. This guide turns that list into an operating procedure: classify the code, decide whether to retry with the same [Idempotency-Key](/concepts/idempotency-and-safe-retries) or fix the request, and know where the error surfaces when the HTTP response was 200. Run the one-step check under each dominant code before you change code, and use the decision table at the end as your triage shortcut.

## 1. The error envelope

Every Orbit API error returns the same envelope:

```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" }
  },
  "meta": {
    "request_id": "req_abc123",
    "timestamp": "2026-09-18T12:00:00Z",
    "docs_url": "https://docs.orbit.devotel.io/errors/INVALID_PHONE_NUMBER"
  }
}
```

* `error.code` is the decision key — branch on it; never parse `error.message` in code.
* `error.status` gives the coarse HTTP class.
* `error.details` carries the structured context that names the fix — which field failed, how many seconds to wait, which domain rule fired.
* `meta.request_id` is the server-side correlation id — include it when you escalate.
* `meta.docs_url` resolves to the code's anchor on the reference page.

For a fuller field walk, see [API error handling by example](/guides/error-handling-examples).

## 2. Taxonomy by HTTP class

| Class     | Examples                                                                 | Policy                                                                                                                               |
| --------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| 400 / 422 | `VALIDATION_ERROR`, `INVALID_PHONE_NUMBER`                               | Terminal until you fix the request body. Do not retry as-is.                                                                         |
| 401 / 403 | `UNAUTHORIZED`, `INSUFFICIENT_SCOPE`, `INSUFFICIENT_PERMISSIONS`         | Terminal for this key. Rotate the key or add the missing scope, then retry.                                                          |
| 402       | `INSUFFICIENT_BALANCE`                                                   | Terminal for this wallet state. Top up, then retry with the same Idempotency-Key.                                                    |
| 404       | `NOT_FOUND`                                                              | Usually terminal. On a resource you just created, one retry is reasonable.                                                           |
| 429       | `RATE_LIMITED`, `WHATSAPP_RATE_LIMITED`, `VERIFY_RECIPIENT_RATE_LIMITED` | Retryable with backoff. Honor the `Retry-After` header or `details.retry_after_seconds`.                                             |
| 5xx       | `INTERNAL_ERROR`, `SERVICE_UNAVAILABLE`                                  | Retryable with exponential backoff and jitter. Use the same Idempotency-Key so a retry that races the original cannot double-create. |
| 502       | `MESSAGE_SEND_FAILED`, `EMAIL_SEND_FAILED`                               | Provider rejected at dispatch — retryable only after the provider check below; otherwise escalate.                                   |

Async gates such as `QUIET_HOURS_BLOCKED` look 4xx-shaped but behave like a clock-based 429: schedule the send inside allowed hours instead of failing the job.

## 3. Dominant codes and their remediation

### `VALIDATION_ERROR` — fix the body, keep the key

The request body or query failed schema validation; the reply is 400 or 422 and `details.issues` lists each offending field (see section 4). Map the issues back to your form, fix the input, and retry with the same Idempotency-Key — replaying a key after fixing the body is exactly what the replay cache is for.

```bash theme={null}
# check: re-send with the field corrected and the same Idempotency-Key — expect 200/201, not a duplicate
curl -X POST https://api.orbit.devotel.io/api/v1/messages \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Idempotency-Key: order-conf-98421" \
  -H "Content-Type: application/json" \
  -d '{"to":"+14155552671","body":"hi"}'
```

### `INSUFFICIENT_SCOPE` — rotate the key, don't toggle the endpoint

The API key authenticates but the token lacks the scope this endpoint needs (for example `messages:write` on sends). Treat 403 as a key/scope problem, not an endpoint outage: issue a new key with the needed scope in the dashboard (Developers → API keys), or have an admin add the scope, then retry. Keep subaccount key scopes minimal.

```bash theme={null}
# check: GET /api/v1/me with the same key returns the scopes it actually carries
curl https://api.orbit.devotel.io/api/v1/me \
  -H "X-API-Key: dv_live_sk_..."
```

### `RATE_LIMITED` — back off, don't hammer

The tenant's sliding-window limiter trips at 429. Read the `Retry-After` header (or `details.retry_after_seconds` on cap-style limits such as `SMS_RATE_LIMITED`) and send again once it expires. In the SDK the error is an `OrbitRateLimitError`, so branch on the class instead of matching the code string.

```bash theme={null}
# check: the response tells you how long to wait — honor it
curl -i -X POST https://api.orbit.devotel.io/api/v1/messages \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Idempotency-Key: throttle-probe-1" \
  -d '{"to":"+14155552671"}' | grep -i retry-after
```

### `INSUFFICIENT_BALANCE` — top up, then replay safely

The wallet pre-flight rejects with 402 before anything dispatches. Top up (or enable auto-top-up) and retry with the same Idempotency-Key: a 402 never dispatched, so reusing the key is safe. Balance mutations additionally guard themselves with `DEDUCT_IN_FLIGHT` so a retry cannot double-charge.

```bash theme={null}
# check: read the wallet balance before you retry
curl https://api.orbit.devotel.io/api/v1/billing/balance \
  -H "X-API-Key: dv_live_sk_..."
```

### `MESSAGE_SEND_FAILED` — check the provider, not the payload

The send reached a provider and the provider rejected it (502). That is different from `VALIDATION_ERROR`: the payload was fine, so check the channel — sender registration, template status, channel health — then retry. If the channel dry-run is unhealthy, fix the channel before sending more traffic; if it is healthy, retry with backoff.

```bash theme={null}
# check: re-pull the failing message record and read its provider reason before retrying
curl https://api.orbit.devotel.io/api/v1/messages/msg_abc123 \
  -H "X-API-Key: dv_live_sk_..."
```

### `QUIET_HOURS_BLOCKED` — schedule, don't retry in place

The universal quiet-hours gate blocks non-voice outbound sends (SMS, MMS, WhatsApp, RCS, Email, Telegram, Viber, Instagram, Messenger, LINE, Apple Messages) during the recipient's local restricted window. Treat it as a scheduling signal: queue the send for the allowed window rather than retrying immediately. The companion `QUIET_HOURS_TIMEZONE_UNKNOWN` means the recipient's timezone could not be resolved — set an explicit timezone on the contact, or schedule into UTC-safe hours.

```bash theme={null}
# check: read the tenant compliance profile to see the active quiet-hours window
curl https://api.orbit.devotel.io/api/v1/compliance/profile \
  -H "X-API-Key: dv_live_sk_..."
```

### Suppression gates — respect the opt-out, route to re-permission

Suppression hits are consent outcomes, not send failures. When a contact sits on the suppression list (opted out, blocked, or bounced), fix it at the contact level — re-permission them through an opt-in flow, or remove the address from your audience — rather than retrying the send. Honoring suppression is a deliverability requirement, not just an error-handling rule.

```bash theme={null}
# check: query the contact's suppression status before attempting a resend
curl https://api.orbit.devotel.io/api/v1/contacts/con_abc123/suppression \
  -H "X-API-Key: dv_live_sk_..."
```

## 4. Walking the per-field issues list

`VALIDATION_ERROR` replies carry `details.issues`, a per-field array from the server-side schema check. Walk it and map each entry back to your form validation instead of showing the raw message:

```json theme={null}
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed",
    "status": 422,
    "details": {
      "issues": [
        { "field": "to",   "message": "Invalid E.164 phone number" },
        { "field": "body", "message": "Required" }
      ]
    }
  }
}
```

```ts theme={null}
for (const issue of error.details?.issues ?? []) {
  form.setError(issue.field, issue.message);
}
```

Each entry has a `field` you map to a form input and a `message` you can show to the operator. Preserve the server's field names — don't re-derive them from the request payload.

## 5. Errors that surface after a 200

The envelope above describes synchronous rejections. Async surfaces — batch jobs, drip steps, and porting orders — accept the request and report failure later:

* **Batch jobs and drip steps** — the create call returns 200/202; per-item failures land on the job status resource and on webhook events. Consume them with a [webhook consumer](/guides/webhook-consumer) and read the item-level failure there instead of polling the request log.
* **Porting orders** — a carrier-side rejection arrives after acceptance; poll the order (or consume its webhook) until `status` flips to `rejected` and read the `rejection` object on the order payload for the carrier's reason.
* **The message ledger** — a 200 on the send endpoint means accepted; delivery failure shows up when you GET the message record as a `failed` status with a provider `rejectReason`.

The rule: once the request is accepted, move your error-handling to the lifecycle surface (webhook or status poll), not the HTTP response.

## 6. Idempotent retry after errors

The [idempotency contract](/concepts/idempotency-and-safe-retries) tells you when replaying a key is safe. Pair it with the code classification:

| Code                     | Retry with same key?          | Backoff first?             | Escalate when…                    |
| ------------------------ | ----------------------------- | -------------------------- | --------------------------------- |
| `VALIDATION_ERROR`       | Yes, after fixing the body    | No                         | The same field keeps failing      |
| `INSUFFICIENT_SCOPE`     | No — rotate the key first     | No                         | Every key with the scope fails    |
| `INSUFFICIENT_BALANCE`   | Yes, after topping up         | No                         | The wallet stays under-funded     |
| `RATE_LIMITED` / 429     | Yes                           | Yes — honor `Retry-After`  | Backoff exhausts                  |
| 5xx                      | Yes                           | Yes — exponential + jitter | It persists across windows        |
| `MESSAGE_SEND_FAILED`    | Only after the provider check | Yes                        | The channel dry-run is unhealthy  |
| `QUIET_HOURS_BLOCKED`    | Reschedule, don't retry       | N/A                        | Compliance must review            |
| Suppression gates        | No — fix the contact consent  | No                         | Suppression trips on new contacts |
| 409 idempotency conflict | No — the body differed        | No                         | Always treat as a client bug      |

Terminal codes (403 scope, 402 balance, 409 conflict, suppression/consent gates) are terminal **for this request shape** — you fix a precondition, then retry. Retryable codes (429, 5xx, provider-rejected dispatch) are safe to retry with the same Idempotency-Key because the platform deduplicates on it.

## 7. Reading errors in the SDK and raw REST

The Node SDK throws an `OrbitApiError` hierarchy; the subclass is chosen by HTTP class, and `.code` holds the stable decision key:

```typescript theme={null}
import { Devotel, OrbitApiError } from '@devotel-orbit/node';

const orbit = new Devotel({ apiKey: process.env.ORBIT_API_KEY });

try {
  await orbit.messages.send({ channel: 'sms', to: '+14155552671', body: 'hi' });
} catch (error) {
  if (error instanceof OrbitApiError) {
    console.error(error.code, error.status, error.requestId, error.details);
    if (error.isRateLimited) {
      // back off using the Retry-After hint
    } else if (error.code === 'INSUFFICIENT_BALANCE') {
      // top up, then retry with the same Idempotency-Key
    }
  }
  throw error;
}
```

Subclass accessors (`isRateLimited`, `isClientError`, `isServerError`, `status`) let you branch without matching code strings; `requestId` and `docsUrl` travel into every log line. Over raw REST, read `error.code` from the JSON envelope the same way. Log the correlation id on either path — it is what support needs to find the server-side trace.

## 8. The generated reference is a floor

The reference at [Error codes](/reference/error-codes) enumerates the SDK-typed union of codes — the codes a compile-time client can plan for. At runtime, treat the `error.code` in the response body as authoritative: new codes land between deployments before the typed union refreshes, and operational codes from channel providers can surface through the envelope without being in the generated table. Branch on the body, and use the reference plus the [troubleshooting hub](/reference/troubleshooting-hub) as the lookup layer, not the ground truth.

## Decision table

| Code                     | Retry?              | Backoff?                   | Escalate?                 |
| ------------------------ | ------------------- | -------------------------- | ------------------------- |
| `VALIDATION_ERROR`       | After body fix      | No                         | Same field keeps failing  |
| `INSUFFICIENT_SCOPE`     | After scope added   | No                         | All scoped keys fail      |
| `INSUFFICIENT_BALANCE`   | After top-up        | No                         | Wallet still low          |
| `RATE_LIMITED` / 429     | Yes                 | Yes (`Retry-After`)        | Backoff exhausts          |
| 5xx                      | Yes                 | Yes (exponential + jitter) | Persists                  |
| `MESSAGE_SEND_FAILED`    | After channel check | Yes                        | Channel unhealthy         |
| `QUIET_HOURS_BLOCKED`    | Reschedule          | N/A                        | Compliance review         |
| Suppression gates        | No                  | No                         | Recurring on new contacts |
| 409 idempotency conflict | No                  | No                         | Client bug                |

Cross-links: [Error codes reference](/reference/error-codes), [API error handling by example](/guides/error-handling-examples), [Idempotency and safe retries](/concepts/idempotency-and-safe-retries), [Consuming webhooks](/guides/webhook-consumer), [Troubleshooting hub](/reference/troubleshooting-hub).
