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

> Everything you need to integrate with Orbit — REST API, webhooks, sandbox, rate limits, error codes, and Postman collections across every channel.

# API Integration

Orbit is API-first. Every feature you can click in the dashboard is backed by a REST endpoint you can call from your own code. This page is the map to the rest of the API reference — where to start, what to test against, and what to watch for in production.

***

## Base URLs

There's one base URL. Sandbox is **not** a separate host — it's the same API, switched into test mode per request.

| Environment              | Base URL                              | Use for                                         |
| ------------------------ | ------------------------------------- | ----------------------------------------------- |
| **Production & Sandbox** | `https://api.orbit.devotel.io/api/v1` | Live traffic is billed; sandbox traffic is free |

You select sandbox by how you authenticate, not by changing the URL:

* **Server-to-server:** use a test secret key (`dv_test_sk_…`). Any request signed with a test key runs in sandbox automatically.
* **From the dashboard (Clerk session):** send `X-Test-Mode: true`. This header is honored only for dashboard sessions — a live API key (`dv_live_sk_…`) cannot flip into sandbox with it.

Sandbox mirrors production endpoints 1:1. Requests don't move real money, don't deduct credits, and don't dispatch real messages (SMS/WhatsApp/RCS/email/voice are simulated, not sent). Simulated sends return deterministic delivery receipts so you can exercise both the success and failure paths.

<Note>
  Sandbox responses carry `"test_mode": true` in the `meta` block. Assert on that to confirm a request ran in sandbox — there is no separate environment header or hostname.
</Note>

***

## Authentication

Every request needs an API key in the `X-API-Key` header:

```bash theme={null}
curl https://api.orbit.devotel.io/api/v1/messages \
  -H "X-API-Key: dv_live_sk_abc123..." \
  -H "Content-Type: application/json"
```

* Keys are per-tenant. Subaccounts get their own scoped keys.
* Keys have prefix: `dv_live_sk_` (production) or `dv_test_sk_` (sandbox).
* Rotate keys anytime at **Settings → API Keys** — rotation is instant, old keys invalidate.
* Leaked a key? Revoke immediately; Orbit does not charge you for traffic after the revoke timestamp.

Full detail: [Authentication](/authentication).

***

## Channels covered by the API

Everything in the dashboard is available via API. Same payloads, same behavior.

| Channel                                        | Endpoint                  | Reference                                           |
| ---------------------------------------------- | ------------------------- | --------------------------------------------------- |
| SMS (including 10DLC, short-code, toll-free)   | `POST /messages/sms`      | [Messaging API](/api-reference/endpoints/messaging) |
| WhatsApp Business                              | `POST /messages/whatsapp` | [Messaging API](/api-reference/endpoints/messaging) |
| RCS Business Messaging                         | `POST /messages/rcs`      | [Messaging API](/api-reference/endpoints/messaging) |
| Viber Tier 1 (one-way basic, SMPP)             | `POST /messages/viber`    | [Viber channel docs](/channels/viber)               |
| Viber Tier 2 (two-way advanced, Business HTTP) | `POST /messages/viber`    | [Viber channel docs](/channels/viber)               |
| Email (SMTP + transactional)                   | `POST /messages/email`    | [Messaging API](/api-reference/endpoints/messaging) |
| Voice (inbound, outbound, AI agent, IVR)       | `/voice/calls`            | [Voice API](/api-reference/voice)                   |
| Verify (OTP codes, Silent Auth)                | `/verify`                 | [Verify API](/api-reference/endpoints/verify)       |
| Numbers (search, purchase, release, port)      | `/numbers`                | [Numbers API](/api-reference/numbers)               |
| Contacts & lists                               | `/contacts`               | [Contacts API](/api-reference/endpoints/contacts)   |
| Campaigns                                      | `/campaigns`              | [Campaigns API](/api-reference/endpoints/campaigns) |
| AI Agents                                      | `/agents`                 | [Agents API](/api-reference/endpoints/agents)       |
| Flows                                          | `/flows`                  | [Flows API](/api-reference/endpoints/flows)         |
| Billing & usage                                | `/billing`                | [Billing API](/api-reference/endpoints/billing)     |

Each channel has its own endpoint and its own request schema under the `/messages` prefix — `POST /messages/sms`, `/messages/whatsapp`, `/messages/rcs`, `/messages/viber`, and `/messages/email`. There is no single channel-polymorphic `/messages` route and no top-level `channel` discriminator: the body shape differs per channel (an SMS send takes `to`/`from`/`body`, a WhatsApp template send takes a `template` block, and so on) rather than being switched by a `channel` field. Pick the endpoint that matches the channel you're sending on.

***

## Your first API call

The fastest way to confirm your key works:

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

Response — `202 Accepted`. The send result is wrapped in the standard envelope (`{ data, meta }`); the message fields live under `data`, and `meta` carries the `request_id` you should log:

```json theme={null}
{
  "data": {
    "id": "msg_01HXYZ...",
    "channel": "sms",
    "direction": "outbound",
    "from": "+16572262362",
    "to": "+15555551234",
    "status": "sent",
    "external_id": "prov_7f3c91...",
    "created_at": "2026-04-17T10:23:00Z"
  },
  "meta": {
    "request_id": "req_01HXYZ...",
    "timestamp": "2026-04-17T10:23:00Z"
  }
}
```

`status` reflects the persisted state at send time (`sent` once the provider accepts, `queued`/`scheduled` for deferred sends, `test_sent` in sandbox). There is no top-level `cost_estimate` on the send response — pull cost and segment counts from `GET /messages/:id` (or the delivery webhook) once billing settles. Sandbox sends add `"test_mode": true` to `meta`.

Check status at `GET /messages/:id` or subscribe to delivery webhooks (recommended).

***

## Webhooks

Webhooks are how Orbit tells your backend about events: a message was delivered, a call connected, an agent escalated a conversation, a number was ported in. You register a URL and we POST events to it.

**Setup**

1. **Dashboard → Settings → Webhooks → Add endpoint**
2. Enter your HTTPS URL (HTTP rejected in production)
3. Pick the event types you want (or subscribe to `*` for everything)
4. Copy the signing secret (`whsec_...`) — you need it to verify signatures

Every Orbit webhook carries an `X-Orbit-Signature` header (the canonical signature). We also still emit `X-Devotel-Signature` with the same value for back-compat — older integrations can keep reading it. During a signing-secret rotation grace window a third header, `X-Orbit-Signature-Next`, carries the rotation-candidate signature. Verify the signature before trusting the payload.

The header is **Stripe-style**, `t=<unix>,v1=<hex>` — not a bare hex digest:

```
X-Orbit-Signature: t=1715357600,v1=4f9c2e6b8a1d3f5e7c9b1a3d5f7e9c1b3a5d7f9e1c3b5a7d9f1e3c5b7a9d1f3e
```

```typescript theme={null}
// Node example
import { createHmac, timingSafeEqual } from "node:crypto";

// Pass the `X-Orbit-Signature` (or back-compat `X-Devotel-Signature`) header value.
function verify(body: string, header: string, secret: string) {
  const [t, v1] = header.split(",").map(p => p.split("=")[1]);
  const expected = createHmac("sha256", secret)
    .update(`${t}.${body}`)
    .digest("hex");
  return timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}
```

Replay protection: reject any signature whose `t` (timestamp) is older than 5 minutes.

**Retries**

Orbit retries failed webhook deliveries on a fixed 6-step schedule: the initial delivery plus up to 6 retries (7 attempts total). Each retry fires a set interval after the previous attempt fails — **1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours, then 24 hours** — not a smooth exponential curve from a short base delay. Every delay gets up to 20% extra jitter on top, so a burst of failures against one endpoint doesn't retry in lockstep and re-overload it on recovery.

Because the last two steps are 8 and 24 hours apart, an endpoint that stays down keeps receiving retries for roughly 35 hours before the schedule is exhausted. Size your delivery monitoring around that full window — the largest gap between two attempts is a full day, not seconds. The event stays in your log either way, so you can replay it from **Dashboard → Webhooks → Event log**.

A webhook is "successful" if your server returns any `2xx` within 30 seconds. Anything else (timeout, `5xx`, `4xx` except `410 Gone`) triggers retry. Return `410 Gone` to permanently disable a single event (e.g., you've deprecated handling for that type).

**Dead endpoints**

If we see 50 consecutive failures, we auto-disable the endpoint and email the org admin. Re-enable it from the dashboard once you've fixed it.

Full detail: [Webhooks overview](/webhooks/overview), [Event catalog](/webhooks/events), [Security](/webhooks/security).

***

## Rate limits

Default limits apply per API key. Higher limits available on request.

| Endpoint family                                        | Default rate    | Notes                                                               |
| ------------------------------------------------------ | --------------- | ------------------------------------------------------------------- |
| `/messages/sms` (send)                                 | **100 / min**   | Per-key, per-channel. There is no shared cross-channel send bucket. |
| `/messages/whatsapp` (send)                            | **80 / min**    | Per-key. Meta has its own WABA throttling on top.                   |
| `/messages/email` (send)                               | **200 / min**   | Per-key.                                                            |
| `/messages/rcs` (send)                                 | **50 / min**    | Per-key. Apple / carrier throttling on top.                         |
| `/messages/viber` (send)                               | **50 / min**    | Per-key.                                                            |
| `/messages` (read)                                     | 120 / min       | Authenticated read cap — `GET /messages` and `GET /messages/:id`.   |
| `/voice/calls` (outbound)                              | 60 / min        | Per-number concurrency limits also apply from your provider.        |
| `/contacts`, `/campaigns`, `/agents`, `/flows` (read)  | 120 / min       | Authenticated read cap — list / get.                                |
| `/contacts`, `/campaigns`, `/agents`, `/flows` (write) | 60 / min        | Authenticated write cap — `POST` / `PUT` / `PATCH` / `DELETE`.      |
| `/numbers/search`                                      | 60 / min        | Fans out to multiple upstream carriers, so it uses the write cap.   |
| `/billing`                                             | 120 / min       |                                                                     |
| Auth / signup / password reset                         | 10 / min per IP | `POST /auth/forgot-password` is tighter at 3 / min per IP.          |

Rate-limit headers are on every response:

```
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 83
X-RateLimit-Reset: 1713357600
```

When you exceed the limit you get `HTTP 429` with:

```json theme={null}
{
  "error": {
    "code": "RATE_LIMITED",
    "message": "Rate limit exceeded. Retry after 12 seconds.",
    "status": 429,
    "details": { "retry_after": 12 }
  },
  "meta": {
    "request_id": "req_01HXYZ...",
    "timestamp": "2026-04-17T10:23:00Z"
  }
}
```

Use `details.retry_after` (seconds), not fixed backoff. Full detail: [Rate Limits](/guides/rate-limits).

***

## Error codes

All errors follow the same shape:

```json theme={null}
{
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Your wallet balance of $0.12 is below the $0.50 minimum to send this WhatsApp conversation. Add funds or lower send volume.",
    "status": 402
  },
  "meta": {
    "request_id": "req_01HXYZ...",
    "timestamp": "2026-04-17T10:23:00Z",
    "docs_url": "https://docs.orbit.devotel.io/errors/INSUFFICIENT_BALANCE"
  }
}
```

Error codes are UPPERCASE constants (e.g. `INSUFFICIENT_BALANCE`) — match on `error.code` exactly. Always log `meta.request_id` — support can trace it end-to-end.

Common codes you'll hit during integration:

| Code                     | HTTP | Meaning                                                 | Fix                            |
| ------------------------ | ---- | ------------------------------------------------------- | ------------------------------ |
| `INVALID_API_KEY`        | 401  | Key revoked, wrong env, or typo                         | Check `Settings → API Keys`    |
| `FORBIDDEN`              | 403  | Tenant suspended — usually billing or compliance        | Check dashboard banner         |
| `RATE_LIMITED`           | 429  | Too many requests                                       | Respect `details.retry_after`  |
| `INSUFFICIENT_BALANCE`   | 402  | Wallet empty or below channel min                       | Top up wallet                  |
| `INVALID_PHONE_NUMBER`   | 422  | Not E.164 or not reachable                              | Validate upstream              |
| `NOT_SMS_CAPABLE`        | 422  | Toll-free without 10DLC reg, or inbound-only number     | Register or swap number        |
| `TEMPLATE_NOT_APPROVED`  | 422  | WhatsApp template still pending                         | Check `status` in `/templates` |
| `CHANNEL_NOT_CONFIGURED` | 422  | Tenant hasn't finished channel onboarding               | Go to `Settings → Channels`    |
| `IDEMPOTENCY_KEY_REUSED` | 409  | Same `Idempotency-Key` reused with different body       | Use unique keys per send       |
| `VALIDATION_ERROR`       | 422  | Payload shape wrong — `details.issues` lists each issue | Read `details.issues`          |

Full list: [Error Codes](/reference/error-codes).

***

## Idempotency

Every `POST` that creates a resource (message, call, contact, campaign) accepts an `Idempotency-Key` header. Use it.

```bash theme={null}
curl https://api.orbit.devotel.io/api/v1/messages/sms \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Idempotency-Key: order-conf-98421" \
  -H "Content-Type: application/json" \
  -d '{...}'
```

Orbit stores the response for 24 hours. Replay the exact same key + body → you get the cached response, not a duplicate send. Replay with a different body → `409 IDEMPOTENCY_KEY_REUSED`.

This is the single most important pattern for production reliability. Use it on every creation endpoint.

***

## Pagination

List endpoints return cursor-paginated results:

```json theme={null}
{
  "data": [...],
  "meta": {
    "pagination": {
      "cursor": "eyJpZCI6Im1zZ18wMUhYWVovLi4uIn0=",
      "has_more": true,
      "total": 1542
    }
  }
}
```

Pass `?cursor=<meta.pagination.cursor>&limit=100` for the next page. No `offset` — cursor is stable against concurrent inserts.

Full detail: [Pagination](/guides/pagination).

***

## Postman collections

Pre-built collections per channel — import one and you're testing in 30 seconds.

| Collection                                        | Import link                                                                                    |
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Orbit — Core (auth, messages, contacts, webhooks) | [postman/orbit-core.json](https://docs.devotel.io/postman/orbit-core.json)                     |
| Orbit — Voice & IVR                               | [postman/orbit-voice.json](https://docs.devotel.io/postman/orbit-voice.json)                   |
| Orbit — Campaigns & Flows                         | [postman/orbit-campaigns.json](https://docs.devotel.io/postman/orbit-campaigns.json)           |
| Orbit — Agents (AI)                               | [postman/orbit-agents.json](https://docs.devotel.io/postman/orbit-agents.json)                 |
| Orbit — Numbers & Verify                          | [postman/orbit-numbers-verify.json](https://docs.devotel.io/postman/orbit-numbers-verify.json) |
| Orbit — Billing & Usage                           | [postman/orbit-billing.json](https://docs.devotel.io/postman/orbit-billing.json)               |

Each collection has variables for `{{base_url}}` and `{{api_key}}` pre-wired. Default environment is sandbox.

Also available: **OpenAPI 3.1 spec** at [`https://api.orbit.devotel.io/api/v1/openapi.json`](https://api.orbit.devotel.io/api/v1/openapi.json) — import into any codegen (openapi-generator, oats, orval, kiota).

***

## SDKs

If you'd rather not hand-roll HTTP, we maintain official SDKs. They handle auth, retries, pagination, webhook signature verification, and typed models.

* [Node.js / TypeScript](/sdks/node) — generally available
* [Web (browser)](/sdks/web) — generally available
* Python, Go, Java, PHP, Ruby, C# / .NET — in beta, pending publication to their package registries (PyPI, pkg.go.dev, Packagist, RubyGems, Maven Central, NuGet). The source lives in the monorepo today; until each is published you can't install it from the package manager, so call the REST API directly — our cURL examples translate cleanly into any language.

Browser: use the [Orbit Web SDK](/sdks/web) for client-side widgets (chat embed, voice browser calling). **Never ship a secret key (`dv_live_sk_…` / `dv_test_sk_…`) to the browser** — client-side code uses a publishable key (`dv_live_pk_…` / `dv_test_pk_…`). The widget then bootstraps a short-lived visitor session JWT by calling `POST /widget/session` with your public `widget_id`; every other widget request carries that token as `Authorization: Bearer <token>`.

***

## Environments & promotion

Recommended integration flow:

1. **Develop** against sandbox with `dv_test_sk_*` keys (or `X-Test-Mode: true` from the dashboard)
2. **Stage** against sandbox with your staging webhook URL (not production)
3. **Prod** swap keys + webhook URL; everything else identical

Sandbox retains data for 30 days then purges. Do not rely on it for long-term storage.

***

## Support

* **API questions & bugs**: [developer-support@devotel.io](mailto:developer-support@devotel.io)
* **Compliance (WhatsApp/10DLC/RCS)**: [compliance@devotel.io](mailto:compliance@devotel.io)
* **Urgent production**: in-app chat (Dashboard → bottom-right) — 24/7 for paid tenants, business hours for Free tier
* **Status page**: [status.orbit.devotel.io](https://status.orbit.devotel.io)

Always include `request_id` from the failing response in any support ticket — it's our fastest route to root cause.
