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

# The five most common error envelopes — worked end-to-end

> See exactly what the Orbit API returns for the five most common terminal errors — 422 validation, 409 idempotency conflict, 429 rate limit, 401 wrong key class, and 403 missing scope — with the full response envelope, a cURL call, and code in Node.js and Python.

# The five most common error envelopes — worked end-to-end

The [Error Code Reference](/reference/error-codes) lists every code the platform can return — 732 at last count. This guide picks the five you will actually hit during integration and shows you the full response body, not just the code. Each section is a complete request-response pair: a cURL call, the exact error envelope you get back, and how to handle it in Node.js and Python.

For the code registry and how to classify errors as retriable or terminal, see [Error Codes](/api-reference/error-codes) and the [Error-handling runbook](/guides/error-handling-runbook).

## 1. 422 — validation failure (`VALIDATION_ERROR`)

A `422` means the request body failed schema validation before any business logic ran. The envelope carries `details.issues`, an array of per-field objects naming the field, the error code, and a human-readable message.

### Request

```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 "Idempotency-Key: order-conf-98421" \
  -H "Content-Type: application/json" \
  -d '{"to": "not-a-number", "channel": "sms"}'
```

### Response — 422

<Accordion title="422 Error">
  ```json theme={null}
  {
    "error": {
      "code": "VALIDATION_ERROR",
      "message": "Request validation failed",
      "status": 422,
      "details": {
        "issues": [
          {
            "field": "to",
            "code": "invalid_phone_number",
            "message": "Must be a valid E.164 phone number"
          },
          {
            "field": "body",
            "code": "required",
            "message": "Required"
          }
        ]
      }
    },
    "meta": {
      "request_id": "req_01J9XK2M3N4P5Q6R7S8T9U0V",
      "timestamp": "2026-10-05T10:23:45Z",
      "docs_url": "https://docs.orbit.devotel.io/errors/VALIDATION_ERROR"
    }
  }
  ```
</Accordion>

The `issues` array is the actionable part. Each entry names the failing `field`, a machine-readable `code` you can branch on, and a `message` you can show to an operator. Walk the array and map each entry back to your form rather than showing the raw JSON:

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

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

try {
  await orbit.messages.send({
    channel: 'sms',
    to: 'not-a-number',
    body: 'Your order shipped.',
  });
} catch (error) {
  if (error instanceof OrbitApiError && error.code === 'VALIDATION_ERROR') {
    const issues = error.details?.issues ?? [];
    for (const issue of issues) {
      // Map the server field name to your form input
      console.error(`${issue.field}: ${issue.message} (${issue.code})`);
    }
  }
  throw error;
}
```

```python theme={null}
import os
from orbit_sdk import OrbitClient, OrbitError

client = OrbitClient(api_key=os.environ["ORBIT_API_KEY"])

try:
    client.messages.send_sms(to="not-a-number", body="Your order shipped.")
except OrbitError as e:
    if e.code == "VALIDATION_ERROR":
        issues = (e.details or {}).get("issues", [])
        for issue in issues:
            print(f"{issue['field']}: {issue['message']} ({issue['code']})")
    raise
```

Fix the fields named in `issues`, then retry with the same `Idempotency-Key` — the replay cache stores the corrected payload and returns the successful result. A 422 never dispatched anything, so reusing the key is safe.

## 2. 409 — idempotency conflict (`CONFLICT`)

When you send a second request with the same `Idempotency-Key` while the first is still running, the platform returns `409 CONFLICT`. The first request owns the lock; the second must wait for it to finish and then replay the cached result.

### Request

```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 "Idempotency-Key: order-conf-98421" \
  -H "Content-Type: application/json" \
  -d '{"to": "+14155552671", "channel": "sms", "body": "Your order shipped."}'
```

### Response — 409 (in-flight lock)

<Accordion title="409 Error">
  ```json theme={null}
  {
    "error": {
      "code": "CONFLICT",
      "message": "A request with this Idempotency-Key is still in progress. Retry after 2 seconds to replay the cached result.",
      "status": 409
    },
    "meta": {
      "request_id": "req_01J9XK3N4P5Q6R7S8T9U0V1W",
      "timestamp": "2026-10-05T10:23:46Z",
      "docs_url": "https://docs.orbit.devotel.io/errors/CONFLICT"
    }
  }
  ```
</Accordion>

The response carries a `Retry-After: 2` header. Wait that interval, then retry with the same key and body. When the first request completes, the retry receives the cached result — the response body you would have gotten from the original — and the header `Idempotency-Replay: true` confirms it was a replay, not a fresh execution.

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

const orbit = new Orbit({ apiKey: process.env.ORBIT_API_KEY });
const idempotencyKey = 'order-conf-98421';

async function sendWithIdempotency() {
  try {
    const result = await orbit.messages.send({
      channel: 'sms',
      to: '+14155552671',
      body: 'Your order shipped.',
    }, { idempotencyKey });

    // The SDK sets isReplay when Idempotency-Replay: true was received
    if (result.isReplay) {
      console.log('Response was replayed — no duplicate send');
    }
    return result;
  } catch (error) {
    if (error instanceof OrbitApiError && error.status === 409) {
      // In-flight lock — wait the server-advised window, then retry
      const retryAfter = error.details?.retry_after ?? 2;
      await new Promise(r => setTimeout(r, retryAfter * 1000));
      return sendWithIdempotency();
    }
    throw error;
  }
}
```

```python theme={null}
import os
import time
from orbit_sdk import OrbitClient, OrbitError

client = OrbitClient(api_key=os.environ["ORBIT_API_KEY"])
idempotency_key = "order-conf-98421"

def send_with_idempotency():
    try:
        result = client.messages.send_sms(
            to="+14155552671",
            body="Your order shipped.",
            idempotency_key=idempotency_key,
        )
        if getattr(result, "replayed", False):
            print("Response was replayed — no duplicate send")
        return result
    except OrbitError as e:
        if e.status == 409:
            retry_after = (e.details or {}).get("retry_after", 2)
            time.sleep(retry_after)
            return send_with_idempotency()
        raise
```

A 409 is always terminal for the current attempt — you fix nothing, just wait. If the body differs from the original, the code is `IDEMPOTENCY_KEY_REUSED` (also 409) and the fix is to correct the body or use a new key.

## 3. 429 — rate limited (`RATE_LIMITED`)

The global request limiter returns `429` with a body that tells you exactly how long to wait. The `Retry-After` header mirrors `error.retry_after` in the body — read whichever your HTTP client surfaces.

### Request

```bash theme={null}
curl -i -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 '{"to": "+14155552671", "channel": "sms", "body": "Hello"}'
```

### Response — 429

<Accordion title="429 Error">
  ```json theme={null}
  {
    "error": {
      "code": "RATE_LIMITED",
      "message": "Rate limit exceeded. Retry after 4 seconds.",
      "status": 429,
      "retry_after": 4
    },
    "meta": {
      "request_id": "req_01J9XK4P5Q6R7S8T9U0V1W2X",
      "timestamp": "2026-10-05T10:23:47Z",
      "docs_url": "https://docs.orbit.devotel.io/errors/RATE_LIMITED"
    }
  }
  ```
</Accordion>

Response headers:

```
Retry-After: 4
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1759662627
X-RateLimit-Bucket: auth-write
```

The `retry_after` value (seconds) reflects the actual bucket refill time. Honor it before any exponential fallback — a blind doubling may retry too soon and hit 429 again.

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

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

async function sendWithBackoff(maxAttempts = 5) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    try {
      return await orbit.messages.send({
        channel: 'sms',
        to: '+14155552671',
        body: 'Hello',
      });
    } catch (error) {
      if (error instanceof OrbitApiError && error.status === 429) {
        const retryAfter = error.details?.retry_after
          ?? error.retryAfter
          ?? Math.min(2 ** (attempt - 1), 32);
        if (attempt === maxAttempts) throw error;
        await new Promise(r => setTimeout(r, retryAfter * 1000));
      } else {
        throw error;
      }
    }
  }
}
```

```python theme={null}
import os
import time
from orbit_sdk import OrbitClient, OrbitRateLimitError

client = OrbitClient(api_key=os.environ["ORBIT_API_KEY"])

def send_with_backoff(max_attempts=5):
    for attempt in range(1, max_attempts + 1):
        try:
            return client.messages.send_sms(
                to="+14155552671", body="Hello"
            )
        except OrbitRateLimitError as e:
            retry_after = getattr(e, "retry_after", None)
            if retry_after is None:
                retry_after = min(2 ** (attempt - 1), 32)
            if attempt == max_attempts:
                raise
            time.sleep(retry_after)
```

Use the same `Idempotency-Key` on the retry — a 429 is always a pre-dispatch rejection, so reusing the key cannot double-send.

The response also carries draft-ietf-httpapi-ratelimit-headers (`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `X-RateLimit-Bucket` and their no-prefix mirrors). Read them to track how close you are to the limit before you get a 429.

## 4. 401 — wrong API key class (`UNAUTHORIZED`)

Orbit API keys come in two classes: secret keys (`dv_live_sk_…`) for server-side calls and publishable keys (`dv_live_pk_…`) for client-side operations that do not move money. Using a publishable key where a secret key is required returns `401`.

### Request — publishable key on a send endpoint

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

### Response — 401

<Accordion title="401 Error">
  ```json theme={null}
  {
    "error": {
      "code": "UNAUTHORIZED",
      "message": "Invalid or missing API key. Check that you are using a live secret key (dv_live_sk_…) for server-side calls, a live publishable key (dv_live_pk_…) for browser calls, or a test key (dv_test_sk_… / dv_test_pk_…) for sandbox.",
      "status": 401
    },
    "meta": {
      "request_id": "req_01J9XK5Q6R7S8T9U0V1W2X3Y",
      "timestamp": "2026-10-05T10:23:48Z",
      "docs_url": "https://docs.orbit.devotel.io/errors/UNAUTHORIZED"
    }
  }
  ```
</Accordion>

This is a key-class mismatch: the request authenticates via `X-API-Key` (or `Authorization: Bearer`) but the key prefix does not match the endpoint's required class. The fix is to use the right key:

| Key prefix | Use for |
| - | - |
| `dv_live_sk_…` | Server-side calls — sends, money moves, provisioning |
| `dv_live_pk_…` | Browser and mobile calls — read-only lookups, embed tokens |
| `dv_test_sk_…` | Sandbox server-side — free, no real sends |
| `dv_test_pk_…` | Sandbox browser/mobile — free, no real sends |

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

// Wrong: publishable key on a send
const orbit = new Orbit({ apiKey: 'dv_live_pk_your_key_here' });

try {
  await orbit.messages.send({
    channel: 'sms',
    to: '+14155552671',
    body: 'Hello',
  });
} catch (error) {
  if (error instanceof OrbitApiError && error.code === 'UNAUTHORIZED') {
    // Key class is wrong — don't retry, fix the key
    console.error('Switch to a secret key (dv_live_sk_…) for server-side calls');
  }
  throw error;
}
```

```python theme={null}
import os
from orbit_sdk import OrbitClient, OrbitAuthenticationError

# Wrong: publishable key on a send
client = OrbitClient(api_key="dv_live_pk_your_key_here")

try:
    client.messages.send_sms(to="+14155552671", body="Hello")
except OrbitAuthenticationError as e:
    if e.code == "UNAUTHORIZED":
        print("Switch to a secret key (dv_live_sk_…) for server-side calls")
    raise
```

## 5. 403 — missing scope (`INSUFFICIENT_PERMISSIONS`)

When the API key authenticates but lacks the scope required for the endpoint, the platform returns `403 INSUFFICIENT_PERMISSIONS`.

### Request

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/voice/calls \
  -H "X-API-Key: dv_live_sk_readonly_key" \
  -H "Content-Type: application/json" \
  -d '{"to": "+14155552671", "from": "+14155552672"}'
```

### Response — 403

<Accordion title="403 Error">
  ```json theme={null}
  {
    "error": {
      "code": "INSUFFICIENT_PERMISSIONS",
      "message": "The API key is missing the required scope 'voice:write' for this endpoint.",
      "status": 403,
      "details": {
        "required_scope": "voice:write",
        "key_scopes": ["messages:read", "voice:read"]
      }
    },
    "meta": {
      "request_id": "req_01J9XK6R7S8T9U0V1W2X3Y4Z",
      "timestamp": "2026-10-05T10:23:49Z",
      "docs_url": "https://docs.orbit.devotel.io/errors/INSUFFICIENT_PERMISSIONS"
    }
  }
  ```
</Accordion>

This is a scope problem, not an auth problem. The key is valid, but it was provisioned without the scope this endpoint needs. Check which scopes your key carries:

```bash theme={null}
curl https://api.orbit.devotel.io/api/v1/me \
  -H "X-API-Key: dv_live_sk_readonly_key"
```

Then either create a new key with the required scope in the dashboard (Developers → API keys), or have an admin add the scope to the existing key. Retry with the updated key.

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

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

try {
  await orbit.voice.calls.create({
    to: '+14155552671',
    from: '+14155552672',
  });
} catch (error) {
  if (error instanceof OrbitApiError && error.code === 'INSUFFICIENT_PERMISSIONS') {
    const required = error.details?.required_scope;
    const current = error.details?.key_scopes;
    console.error(
      `Key missing scope '${required}'.` +
      `Current scopes: ${current?.join(', ')}.` +
      `Add '${required}' to this key in the dashboard, then retry.`
    );
  }
  throw error;
}
```

```python theme={null}
import os
from orbit_sdk import OrbitClient, OrbitError

client = OrbitClient(api_key=os.environ["ORBIT_API_KEY"])

try:
    client.voice.create_call(to="+14155552671", from_="+14155552672")
except OrbitError as e:
    if e.code == "INSUFFICIENT_PERMISSIONS":
        required = (e.details or {}).get("required_scope", "unknown")
        current = (e.details or {}).get("key_scopes", [])
        print(
            f"Key missing scope '{required}'. "
            f"Current scopes: {', '.join(current)}. "
            f"Add '{required}' to this key in the dashboard, then retry."
        )
    raise
```

A 403 is terminal for the current key — do not retry without first fixing the scope.

## Distinguishing 401 from 403

| | 401 `UNAUTHORIZED` | 403 `INSUFFICIENT_PERMISSIONS` |
| - | - | - |
| What happened | The key itself is not recognized, or its class is wrong for this endpoint | The key is valid but lacks the required scope |
| Typical cause | Using `dv_live_pk_…` where `dv_live_sk_…` is needed, or an expired/revoked key | Key scoped to `messages:read` but endpoint needs `messages:write` |
| Fix | Use the correct key prefix or rotate the key | Add the required scope in the dashboard and retry |
| Retry? | Only with the correct key | Only after the scope is added |

Check `GET /me` with the same key to confirm which case you are in: a 200 with the key's scopes means 403; a 401 on `/me` means the key itself is the problem.

## Where to go next

* [Error Codes](/api-reference/error-codes) — the common codes with the envelope shape
* [Error Code Reference](/reference/error-codes) — every registered code (732 total)
* [Error-handling runbook](/guides/error-handling-runbook) — classify any code as retriable or terminal
* [API error handling by example](/guides/error-handling-examples) — retry wrappers in six languages
* [Idempotent requests](/guides/idempotent-requests) — the full idempotency contract


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.