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

# REST API recipes: task-by-task curl and SDK cookbook

> Task-oriented REST API recipes — send and track a message, back off on 429, run an OTP round trip, page a list, import contacts, pre-screen a destination with a risk score, run a loyalty earn-preview-redeem loop, open an OAuth integration and pull synced records, script a teamwork channel over team chat, then the pillar-level loops: place a voice call, rent and port a number, send a WhatsApp template, ask the agent copilot, build a CDP segment, run a Flow. Each recipe pairs curl with Node or Python and links the canonical endpoint page.

# REST API recipes

This is the cookbook the [SDK index](/sdks/index) points at: since no SDK is published to a package registry yet, every recipe here is REST-first. The first ten recipes pair curl with the [Node SDK](/sdks/node) (source-only, unpublished); the six added below (voice, numbers, WhatsApp templates, AI agents, CDP segments, flows) pair curl with raw [Python](/sdks/python) — the SDK modules (`client.voice`, `client.numbers`) where one exists, `requests` against the REST path where scope has not caught up. Between the two languages you can lean on whichever you read faster. Each task is self-contained; run it against the sandbox with a `dv_test_sk_…` key, then swap in your live key.

Authenticate every request with `X-API-Key`. Base URL is `https://api.orbit.devotel.io/api/v1` — sandbox is the same URL with a test key, not a separate host. Full primer: [API integration](/guides/api-integration).

## Task index

| #  | Task                                                                                                    | Endpoints used                                                                                          |
| -- | ------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| 1  | [Send a message, poll status, receive the webhook](#1-send-a-message-poll-status-receive-the-webhook)   | `POST /messages/sms`, `GET /messages/:id`, webhook                                                      |
| 2  | [Handle a 429 with `retry_after`](#2-handle-a-429-with-retry_after)                                     | `POST /messages/sms` (429 path)                                                                         |
| 3  | [Verify an OTP, end to end](#3-verify-an-otp-end-to-end)                                                | `POST /verify/send`, `POST /verify/check`                                                               |
| 4  | [List and cursor-paginate](#4-list-and-cursor-paginate)                                                 | `GET /messages`                                                                                         |
| 5  | [Batch-import contacts, poll the import job](#5-batch-import-contacts-poll-the-import-job)              | `POST /contacts/imports`, `GET /contacts/imports/:id`                                                   |
| 6  | [Score a destination before you send](#6-score-a-destination-before-you-send)                           | `POST /risk/score`                                                                                      |
| 7  | [Run a loyalty earn → preview → redeem round trip](#7-run-a-loyalty-earn--preview--redeem-round-trip)   | `POST /loyalty/preview`, `GET /loyalty/members/:contactId`, `POST /loyalty/members/:contactId/redeem`   |
| 8  | [Open an OAuth connection and pull synced records](#8-open-an-oauth-connection-and-pull-synced-records) | `POST /integrations/connect`, `GET /integrations/:id/status`, `GET /integrations/:id/data`              |
| 9  | [Coordinate the team in a team-chat channel](#9-coordinate-the-team-in-a-team-chat-channel)             | `GET /team-chat/channels`, `POST /team-chat/channels/:id/messages`                                      |
| 10 | [Errors as recipes](#10-errors-as-recipes)                                                              | decision table                                                                                          |
| 11 | [Place an outbound voice call](#11-place-an-outbound-voice-call)                                        | `POST /voice/calls`, `GET /voice/calls/:id`, webhook                                                    |
| 12 | [Rent a number and start a port-in](#12-rent-a-number-and-start-a-port-in)                              | `GET /numbers/available/block`, `POST /numbers`, `POST /numbers/porting/check`, `POST /numbers/porting` |
| 13 | [Publish a WhatsApp template and send it](#13-publish-a-whatsapp-template-and-send-it)                  | `GET /templates`, `POST /messages/whatsapp`                                                             |
| 14 | [Converse through the agent copilot](#14-converse-through-the-agent-copilot)                            | `POST /conversations/:id/copilot/suggest`                                                               |
| 15 | [Build a CDP segment and read its members](#15-build-a-cdp-segment-and-read-its-members)                | `GET /contacts/segments`, saved-segment exports                                                         |
| 16 | [Run a Flow and inspect the execution](#16-run-a-flow-and-inspect-the-execution)                        | `POST /flows`, `POST /flows/:id/execute`, `GET /flows/executions/:id`                                   |

## 1. Send a message, poll status, receive the webhook

Send one SMS, learn its id, then track delivery two ways: a direct status poll and a delivery webhook. Webhooks are the production answer — poll once to confirm the send works, then move to events.

**Send:**

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

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

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

const message = await orbit.messages.send({
  channel: 'sms',
  to: '+14155552671',
  body: 'Your order 98421 shipped.',
});
// message.id — keep it for the status poll below.
```

A `202 Accepted` returns the persisted send in the standard envelope. `data.id` (`msg_…`) is your handle; `data.status` starts as `sent` (or `test_sent` in sandbox) and moves as the carrier reports.

**Poll status once:**

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

```typescript Node SDK theme={null}
const status = await orbit.messages.get('msg_01HXYZ');
// status.status: sent | delivered | failed | queued | test_sent
```

**Receive the webhook instead.** Register one endpoint in **Settings → Webhooks** subscribed to `message.delivered` and `message.failed` (or `*` for everything). Orbit POSTs each status change to you; verify the `X-Orbit-Signature` header before trusting the body — verification is one function call per [API integration → Webhooks](/guides/api-integration#webhooks). Polling is for the first integration smoke test; production consumers subscribe.

Canonical pages: [Messaging API](/api-reference/endpoints/messaging), [Webhook events](/webhooks/events).

## 2. Handle a 429 with `retry_after`

When your send rate passes the per-key limit, the API answers `429` with a body that names the wait inside the envelope (`error.retry_after`) and again as a `Retry-After` header. Read whichever your HTTP client exposes; honor it before any fallback backoff — doubling on your own can retry inside the window and re-429.

```bash cURL theme={null}
# The 429 body is self-describing; extract retry_after with jq.
RESP=$(curl -s -X POST 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": "+14155552671", "body": "hi"}')

echo "$RESP" | jq -r '.error.retry_after // 0'  # seconds to wait
```

```typescript Node SDK theme={null}
import { OrbitRateLimitError } from '@devotel-orbit/node';

try {
  await orbit.messages.send({ channel: 'sms', to, body });
} catch (e) {
  if (e instanceof OrbitRateLimitError) {
    // 429 — the server-provided wait is in `details`.
    const waitSeconds = Number(e.details?.retry_after ?? 5);
    await new Promise(r => setTimeout(r, waitSeconds * 1000));
    // retry here — same idempotency key if you set one, so a retry can't double-send
  } else {
    throw e;
  }
}
```

The full retry-wrapper pattern (capped exponential fallback when the server didn't name a wait) lives in [API error handling by example](/guides/error-handling-examples#b-rate_limited-429-read-retry-after-back-off-exponentially); per-channel rates are in [API integration → Rate limits](/guides/api-integration#rate-limits).

## 3. Verify an OTP, end to end

Two requests: send the code, then check what the user typed. The platform generates, delivers, and expires the code; your backend never stores or compares it.

**Send** — capture `data.verification_id` from the response:

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/verify/send \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+14155552671",
    "channel": "sms",
    "code_length": 6
  }'
```

```typescript Node SDK theme={null}
const verification = await orbit.verify.send({
  to: '+14155552671',
  channel: 'sms',
  codeLength: 6,
});
// verification.verification_id — pass to check with the user's code.
```

**Check** — the user's answer plus the id from send:

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/verify/check \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "verification_id": "vrf_9f2a1c…",
    "code": "483920"
  }'
```

```typescript Node SDK theme={null}
const result = await orbit.verify.check({
  verificationId: 'vrf_9f2a1c…',
  code: '483920',
});
// result.status: approved | failed | expired
```

`status: "approved"` is the only pass. `failed` means the code didn't match; re-check with the same `verification_id` until `attempts_remaining` hits 0, then send a fresh one. `expired` means the send's TTL lapsed — send again. In sandbox, a test key simulates delivery; find the expected code in the verification's dashboard row.

Canonical page: [Verify API](/api-reference/endpoints/verify). A longer REST-only walkthrough (the same two calls plus webhook fallback): [Verify integration without our SDK](/guides/verify-no-sdk).

## 4. List and cursor-paginate

List endpoints return stable cursors — no offsets, so concurrent inserts can't shift your page. Request the first page without a cursor, then pass `meta.pagination.cursor` until `meta.pagination.has_more` is `false`.

```bash cURL theme={null}
# page 1
curl "https://api.orbit.devotel.io/api/v1/messages?limit=100" \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"

# page N — substitute the cursor from the previous response
curl "https://api.orbit.devotel.io/api/v1/messages?limit=100&cursor=eyJpZCI6Im1zZ18wMUhYWVovLi4uIn0=" \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```typescript Node SDK theme={null}
let cursor: string | undefined;
do {
  const page = await orbit.messages.list({ limit: 100, cursor });
  for (const message of page.data) {
    // consume one message per iteration
  }
  cursor = page.meta.pagination.has_more
    ? page.meta.pagination.cursor
    : undefined;
} while (cursor);
```

A wrong or expired cursor is a 422 `INVALID_CURSOR` — restart the iteration without a cursor; never reuse cursors across requests changed in filters or across retries older than the same-minute window. Full table of cursor-vs-offset endpoints: [Pagination](/guides/pagination).

## 5. Batch-import contacts, poll the import job

For anything past a few hundred rows, skip per-row `POST /contacts` calls and enqueue one async import. The endpoint accepts the parsed CSV as a rows array, returns `202` with a `job_id`, and runs the insert in the background. Poll the job id for progress.

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/contacts/imports \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "file_name": "launch-list.csv",
    "merge_strategy": "skip",
    "rows": [
      { "phone": "+14155552671", "first_name": "Ada", "source": "launch_list" },
      { "phone": "+442071234567", "first_name": "Grace", "source": "launch_list" }
    ]
  }'
```

```typescript Node SDK theme={null}
const enqueued = await orbit.contacts.imports.create({
  fileName: 'launch-list.csv',
  mergeStrategy: 'skip', // 'merge' updates existing rows instead
  rows: [
    { phone: '+14155552671', first_name: 'Ada', source: 'launch_list' },
    { phone: '+442071234567', first_name: 'Grace', source: 'launch_list' },
  ],
});
// enqueued.job_id — poll it below.
```

**Poll the job** until `status` is no longer `pending`/`running`:

```bash cURL theme={null}
curl https://api.orbit.devotel.io/api/v1/contacts/imports/imp_01HZYK \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```typescript Node SDK theme={null}
const job = await orbit.contacts.imports.get('imp_01HZYK');
// job.status: pending | running | completed | failed | cancelled
```

`merge_strategy: "skip"` (the default) leaves existing contacts untouched on duplicate keys; `"merge"` updates them with your new values. A completed row besides the failed ones reports per-row outcomes; rows that failed validation are listed as skipped, not imported as empty records. A `503 IMPORT_QUEUE_UNAVAILABLE` means the background queue is briefly down — retry after a few seconds; don't fall back to hundreds of single creates under load.

Canonical pages: [Contacts API](/api-reference/endpoints/contacts), [Import contacts guide](/guides/import-contacts).

## 6. Score a destination before you send

`POST /risk/score` fuses the platform's fraud detectors into one 0–100 verdict you can query *before* committing an SMS send or verify. It's read-only and advisory — it never dispatches, so it's safe to burn before every high-cost send.

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/risk/score \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "destination": "+14155552671",
    "channel": "sms",
    "message_body": "Your order shipped — track it at https://acme.example/t/98421"
  }'
```

```typescript Node SDK theme={null}
const score = await orbit.request('POST', '/api/v1/risk/score', {
  destination: '+14155552671',
  channel: 'sms',
  messageBody: 'Your order shipped — track it at https://acme.example/t/98421',
});
// score.score (0–100), score.band (low|elevated|high|critical),
// score.recommendation (allow|review|block)
```

Branch on `recommendation`: `allow` — proceed to the send; `review` — queue for a human only if the send is high-value; `block` — suppress the send and log the decision. `band` is the same verdict bucketed; `score` (0–100) is the worst-signal composite — the per-channel breakdown names which detector fired. This is a gate on *your* send decision, not a platform block: enforcement stays in your code, and outbound SMS still exits only through the platform.

Canonical page: [Risk API](/api-reference/endpoints/risk). The pumping-attack context this protects against: [SMS pumping protection](/guides/sms-pumping-protection).

## 7. Run a loyalty earn → preview → redeem round trip

Points come from events you already send to the CDP — loyalty computes a member's balance from your event history when you check it, so there is no separate loyalty ledger to sync. The round trip: dry-run the program with preview, read a member's balance, then burn points.

**Preview** — evaluate your active program (or an override in the request body) against sample events. Nothing writes to the database, so burn this freely while you tune earn rules:

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/loyalty/preview \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "events": [
      { "event_name": "account.created" },
      { "event_name": "order-completed", "properties": { "total": 189.95 } }
    ]
  }'
```

```typescript Node SDK theme={null}
const preview = await orbit.request('POST', '/api/v1/loyalty/preview', {
  events: [
    { event_name: 'account.created' },
    { event_name: 'order-completed', properties: { total: 189.95 } },
  ],
});
// preview.program_source: default | custom
// preview.state — per-event attribution, earned, lifetime, tier
```

**Read a member's balance** — points accrue from CDP events your app already emits (track on the [CDP API](/api-reference/endpoints/cdp)); the read endpoint projects those events into the member's standing on demand. Two compensation events join `order-completed` and `loyalty.points_redeemed` in the ledger: `loyalty.points_adjusted` (an operator's manual credit or debit) and `loyalty.program_configured` (a revision under a config sentinel contact that members never write):

```bash cURL theme={null}
curl https://api.orbit.devotel.io/api/v1/loyalty/members/cnt_9f8a7b6c \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```typescript Node SDK theme={null}
const member = await orbit.request(
  'GET',
  '/api/v1/loyalty/members/cnt_9f8a7b6c',
);
// member.contact_id, member.balance, member.tier, member.traits
```

`balance` is the spendable remainder; use `GET /loyalty/members` (paginated) to list every member or a specific id for one. The `traits` block also exposes `loyalty_points_balance`, `loyalty_tier`, and friends exactly as segments and journeys see them.

**Redeem** — burn points atomically. Concurrent redemptions against the same contact serialize, so a double-click can't overspend:

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/loyalty/members/cnt_9f8a7b6c/redeem \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "points": 500, "reward": "10% off next order", "reference": "ord_9182" }'
```

```typescript Node SDK theme={null}
const redemption = await orbit.request(
  'POST',
  '/api/v1/loyalty/members/cnt_9f8a7b6c/redeem',
  { points: 500, reward: '10% off next order', reference: 'ord_9182' },
); // 201 Created
// redemption.redemption_event_id — your handle; debited; new balance
```

Success is `201 Created` with the redemption event id and the post-burn balance. An overspend returns `409 INSUFFICIENT_POINTS` with the available remainder — burden the retry on a smaller amount or abort. Manual operator adjustments (`POST /loyalty/members/:contactId/adjust`) share the same overspend-safe path.

Canonical page: [Loyalty API](/api-reference/loyalty). The program-design walkthrough this loop plugs into: [Loyalty program setup](/guides/loyalty-program).

## 8. Open an OAuth connection and pull synced records

The integrations loop is connect → status → data: start the OAuth flow, confirm the connection formed, then read the synced records. Connect and sync trigger are owner/admin-gated; status and data are reads any scoped key can run.

**Start the connect flow** — get the `auth_url` you redirect the operator to:

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/integrations/connect \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "integration_id": "hubspot" }'
```

```typescript Node SDK theme={null}
const connection = await orbit.request('POST', '/api/v1/integrations/connect', {
  integrationId: 'hubspot',
});
// connection.auth_url — redirect the operator here; the provider's
// consent screen completes the handshake.
```

A provider with no OAuth credentials registered fails `404`; an integration server misconfigured answers `503`. Either is terminal — fix in **Settings → Integrations**, not in your retry loop.

**Confirm status** — the OAuth popup closing is not proof the connection formed; ask before you read data:

```bash cURL theme={null}
curl https://api.orbit.devotel.io/api/v1/integrations/hubspot/status \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```typescript Node SDK theme={null}
const status = await orbit.request('GET', '/api/v1/integrations/hubspot/status');
// status.connected — false means the popup closed without consent,
// the provider rejected, or credentials never formed.
```

`connected: false` resolves cleanly (with an empty sync list) rather than an error, so branch on it without a try/catch. Once connected, the same payload carries the connection's metadata and per-sync status.

**Pull synced records** — name the model (`contacts`, `deals`, …) in the query:

```bash cURL theme={null}
curl "https://api.orbit.devotel.io/api/v1/integrations/hubspot/data?model=contacts" \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```typescript Node SDK theme={null}
const records = await orbit.request(
  'GET',
  '/api/v1/integrations/hubspot/data?model=contacts',
);
// records — the synced rows for that model
```

An empty array is a valid answer — it means the connection is live but the first sync hasn't landed records for that model yet. An owner/admin kicks an immediate run with `POST /integrations/:id/sync { "sync_name": "contacts" }` instead of waiting for the scheduled cadence; a `502` on data reads means the upstream fetch failed, retry after a beat.

Canonical page: [Integrations API](/api-reference/integrations). The full CRM loop (webhooks, writeback, debugging): [Connect HubSpot & Salesforce end to end](/guides/hubspot-salesforce-integration).

## 9. Coordinate the team in a team-chat channel

Team chat is the internal surface — channels, DMs, and huddles for the operators running your workspace, membership-gated so nothing ever reaches a customer. Useful when a back-office bot or dashboard posts handoff notes: list your channels, post to one, read it back.

**List your channels** — every read is membership-scoped, so you see only channels your API key's principal belongs to:

```bash cURL theme={null}
curl https://api.orbit.devotel.io/api/v1/team-chat/channels \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```typescript Node SDK theme={null}
const channels = await orbit.request('GET', '/api/v1/team-chat/channels');
// channels[] — ids you can post into; a non-member gets 403.
```

**Post to a channel:**

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/team-chat/channels/tch_abc123/messages \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "body": "Handoff complete — incident inc_123 resolved." }'
```

```typescript Node SDK theme={null}
const posted = await orbit.request(
  'POST',
  `/api/v1/team-chat/channels/${channelId}/messages`,
  { body: 'Handoff complete — incident inc_123 resolved.' },
);
// posted.id — handle for threads, edits, reactions below.
```

A `403` here is a membership gate, not a key problem — the same principal must be on the channel from the same identity; have the owner add you with `POST /team-chat/channels/:id/members`. Reactions, threads, DMs, and huddles follow the same shape off the message id.

Canonical page: [TeamChat API](/api-reference/team-chat). The operator workflows behind it: [Team Chat guide](/guides/team-chat).

## 10. Errors as recipes

Errors are recipes too — the failure classes in [API error handling by example](/guides/error-handling-examples) map one-to-one onto the tasks above. Branch on `error.code`, never on `message` text.

| Failure                                   | `error.code`                                                                      | HTTP | This cookbook's response                                                                                                                                          |
| ----------------------------------------- | --------------------------------------------------------------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Non-E.164 `to` on send                    | `INVALID_PHONE_NUMBER`                                                            | 422  | Normalize the destination, resend — same body, same 422 if you don't.                                                                                             |
| Send over key limit                       | `RATE_LIMITED`                                                                    | 429  | [Task 2](#2-handle-a-429-with-retry_after): read `retry_after`, wait, retry.                                                                                      |
| Over-cap recipient                        | `FREQUENCY_CAP_EXCEEDED`                                                          | 429  | Defer that recipient by `details.retry_after_seconds`; continue the batch.                                                                                        |
| Key issue                                 | `INVALID_API_KEY`                                                                 | 401  | Check the key's prefix (`dv_test_sk_` vs `dv_live_sk_`) and revocation state in **Settings → API Keys**.                                                          |
| Wallet below channel minimum              | `INSUFFICIENT_BALANCE`                                                            | 402  | Top up; do not retry the same send.                                                                                                                               |
| Reused idempotency key                    | `IDEMPOTENCY_KEY_REUSED`                                                          | 409  | Key + *different* body — generate a fresh key per logical send.                                                                                                   |
| Bad cursor on a list                      | `INVALID_CURSOR`                                                                  | 422  | [Task 4](#4-list-and-cursor-paginate): restart iteration without a cursor.                                                                                        |
| Import queue down                         | `IMPORT_QUEUE_UNAVAILABLE`                                                        | 503  | [Task 5](#5-batch-import-contacts-poll-the-import-job): retry after a few seconds.                                                                                |
| Wrong/expired OTP code                    | *(status `failed` / `expired` on `/verify/check`, not a thrown error)*            | 200  | [Task 3](#3-verify-an-otp-end-to-end): retry the same `verification_id` until attempts run out, then resend.                                                      |
| Loyalty overspend                         | `INSUFFICIENT_POINTS`                                                             | 409  | [Task 7](#7-run-a-loyalty-earn--preview--redeem-round-trip): read the available remainder and retry smaller, or abort — never re-send the same points.            |
| Integration not connected                 | *(body `connected: false` on `GET /integrations/:id/status`, not a thrown error)* | 200  | [Task 8](#8-open-an-oauth-connection-and-pull-synced-records): the OAuth popup closed without consent — re-open the connect flow, don't loop on data.             |
| Provider OAuth credentials not registered | `404` from `/integrations/connect`                                                | 404  | [Task 8](#8-open-an-oauth-connection-and-pull-synced-records): register the provider's OAuth credentials in **Settings → Integrations**; terminal, not retryable. |
| Team-chat principal not on channel        | `NOT_A_MEMBER`                                                                    | 403  | [Task 9](#9-coordinate-the-team-in-a-team-chat-channel): have a channel owner add the principal (`POST /team-chat/channels/:id/members`); retrying never helps.   |

Anything not in this table: read `meta.docs_url` in the error envelope — it links to the code's remedy in the [Error Code Reference](/reference/error-codes). Branch on the retriable-vs-terminal table in [API error handling by example](/guides/error-handling-examples#3-retriable-vs-terminal).

## 11. Place an outbound voice call

Originate a call, then track it to completion either by polling or — in production — by subscribing to `call.completed` in your webhook endpoint. The `to`/`from` are enough for a ring-only call; pass `answer_url` (HTTPS, on your server) returned-verb `say`/`gather`/`dial` instructions when the call answers. The second snippet uses the Python SDK's typed voice module.

**Place:**

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/voice/calls \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+14155552671",
    "from": "+16572262362",
    "record": true,
    "answer_url": "https://your-server.example/voice/answer-flow"
  }'
```

```python Python SDK theme={null}
from orbit_sdk import OrbitClient

client = OrbitClient.from_api_key("dv_test_sk_YOUR_KEY")
call = client.voice.create(to="+14155552671", from_="+16572262362", record=True)
# call["data"]["id"] — keep it for the poll below.
```

A `201 Created` returns the created call in the standard envelope — `data.id` (`call_…`) is your handle. Full-body reference (`record`/`amd`/`metadata`, the `answer_url` vs inline-verb tradeoff): [Voice API](/api-reference/endpoints/voice).

**Poll once:**

```bash cURL theme={null}
curl https://api.orbit.devotel.io/api/v1/voice/calls/call_9f2a1c \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```python Python SDK theme={null}
call = client.voice.get("call_9f2a1c")
# call["data"]["status"] — ringing | in_progress | completed | failed
```

**Receive the webhook instead.** Subscribe your endpoint to `call.completed` (and `call.failed`) in **Settings → Webhooks**; verify `X-Orbit-Signature` before trusting the body, same contract as message DLRs. The full event list: [Webhook events](/webhooks/events).

Canonical page: [Voice API](/api-reference/endpoints/voice).

## 12. Rent a number and start a port-in

Search the inventory for a contiguous block, purchase one into your tenant, then — when the digits you actually want live at another carrier — pre-check portability and open a port-in request.

**Search available numbers:**

```bash cURL theme={null}
curl "https://api.orbit.devotel.io/api/v1/numbers/available/block?country=US&type=local&block_size=5" \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```python Python SDK theme={null}
from orbit_sdk import OrbitClient

client = OrbitClient.from_api_key("dv_test_sk_YOUR_KEY")
available = client.numbers.search(country="US", type="local", capabilities=["sms", "voice"])
```

**Purchase one:**

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/purchase \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "phone_number": "+14155550001", "country_code": "US" }'
```

```python Python SDK theme={null}
owned = client.numbers.purchase(number="+14155550001")
# owned["data"]["id"] (num_…) is your handle for webhooks/release below.
```

**Port an existing number from another carrier** — pre-check eligibility, then open the request:

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/porting/check \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "numbers": ["+14155552671"] }'

# Then open the port-in request:
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/porting \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "numbers": ["+14155552671"],
    "currentCarrier": "Legacy Telco",
    "accountNumber": "ACCT-99121"
  }'
```

```python Python SDK theme={null}
# numbers scope to date is search/purchase — deeper porting calls via raw requests:
import requests, os
headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
check = requests.post(
  "https://api.orbit.devotel.io/api/v1/numbers/porting/check",
  headers=headers, json={"numbers": ["+14155552671"]})
check.json()["data"]["eligible"]  # branch before creating the request
```

Track the port's state on `GET /numbers/porting` (list of your open requests) — the carrier completes it asynchronously; don't poll the check endpoint. Full catalogue — rent/release/webhook binding and port lifecycle: [Numbers API](/api-reference/endpoints/numbers).

## 13. Publish a WhatsApp template and send it

Outside Meta's 24-hour customer-service window, every WhatsApp send has to ride an approved template. The loop: list your templates to find the approved name, then send it with positional variables.

**Find the approved template** (the `data` array carries each template's Meta status — `APPROVED`/`PENDING`/`REJECTED` — so you never copy a name that hasn't cleared review):

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

```python Python theme={null}
import requests, os
headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
templates = requests.get("https://api.orbit.devotel.io/api/v1/templates", headers=headers)
approved = [t for t in templates.json()["data"] if t.get("status") == "APPROVED"]
```

**Send the template** with `template_params` filling positional `{{1}}…{{N}}` placeholders (or Meta-native `components`):

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/messages/whatsapp \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+14155552671",
    "type": "template",
    "template": {
      "name": "order_shipped",
      "language": { "code": "en_US" }
    },
    "template_params": { "1": "order 98421" }
  }'
```

```python Python theme={null}
send = requests.post(
  "https://api.orbit.devotel.io/api/v1/messages/whatsapp",
  headers=headers,
  json={
    "to": "+14155552671",
    "type": "template",
    "template": {"name": "order_shipped", "language": {"code": "en_US"}},
    "template_params": {"1": "order 98421"},
  })
```

The `language.code` must be the exact locale the template was approved under in WhatsApp Manager, or the send is rejected with `422 WHATSAPP_TEMPLATE_NOT_FOUND` (Meta 132001). Poll `GET /messages/:id` for the DLR. Creating new templates flows through the same `/templates` surface — full parameter reference: [Messaging API](/api-reference/endpoints/messaging).

## 14. Converse through the agent copilot

When a conversation reaches the unified inbox, an operator can ask the AI copilot for a suggested reply instead of drafting one. The loop: a live conversation id (from the inbox feed or a webhook), then one request per suggestion.

**Ask the copilot on a conversation:**

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/conversations/conv_9c2b1d/copilot/suggest \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "lookback": 25 }'   # how many recent messages the model reads (1–50)
```

```python Python theme={null}
import requests, os
headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
suggest = requests.post(
  "https://api.orbit.devotel.io/api/v1/conversations/conv_9c2b1d/copilot/suggest",
  headers=headers, json={"lookback": 25})
options = suggest.json()["data"]["suggestions"]
```

Each suggestion comes back scored for grounding — paste it, edit it, or discard it operator-side. Pick the conversation id off `GET /conversations` (the unified-inbox feed) or a `conversation.created` webhook. Supporting knowledge for the copilot's answers lives in the [Knowledge bases API](/api-reference/endpoints/knowledge-bases); the conversation family: [Conversations API](/api-reference/endpoints/conversations).

## 15. Build a CDP segment and read its members

Segments are the CDP's saved audiences — an AND/OR filter over traits, events, and scores that auto-refreshes as events land. Create one, then page its membership when you need the actual contacts behind it.

**Create a segment:**

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/contacts/segments \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "high-value-spenders",
    "rules": {
      "operator": "and",
      "conditions": [
        { "field": "loyalty_points_balance", "operator": "gte", "value": 1000 }
      ]
    }
  }'
```

```python Python theme={null}
import requests, os
headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
segment = requests.post(
  "https://api.orbit.devotel.io/api/v1/contacts/segments",
  headers=headers,
  json={
    "name": "high-value-spenders",
    "rules": {
      "operator": "and",
      "conditions": [
        {
          "field": "loyalty_points_balance",
          "operator": "gte",   # equals|contains|gt|lt|gte|lte|in|is_set|…
          "value": 1000,
        }
      ],
    },
  })
segment_id = segment.json()["data"]["id"]  # seg_…
```

A `201 Created` returns the created segment already materialised — `data.contact_count` is live immediately, not a scheduler estimate.

**Read members:**

```bash cURL theme={null}
curl "https://api.orbit.devotel.io/api/v1/contacts/segments/seg_9c2b1q/members?limit=100" \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```python Python theme={null}
members = requests.get(
  f"https://api.orbit.devotel.io/api/v1/contacts/segments/{segment_id}/members?limit=100",
  headers=headers)
# cursor-paginate, same contract as Task 4
```

Cursor-page the members list exactly as you would any list endpoint (Task 4). Segments refresh automatically — `auto_refresh` (default true) keeps membership current as new CDP events update the fields the rules read. Programmatic `POST /contacts/segments` is an alternative to the dashboard builder; the full filter shape and exports: [Segments API](/api-reference/endpoints/segments).

## 16. Run a Flow and inspect the execution

Flows are the visual automation graph a tenant builds in the dashboard — a trigger into a directed set of steps (delays, branches, channel sends). Create or fetch a flow id, trigger a run, then read the execution back step-by-step.

**Create a flow** (the shape lives in your dashboard — `POST /flows` saves the graph; subsequent `POST /flows/:id/publish` versions it live):

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/flows \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "welcome-bounce-back",
    "definition": {
      "nodes": [ { "id": "n1", "type": "trigger" } ],
      "edges": []
    }
  }'
```

```python Python theme={null}
import requests, os
headers = {"X-API-Key": os.environ["ORBIT_API_KEY"]}
flow = requests.post(
  "https://api.orbit.devotel.io/api/v1/flows",
  headers=headers,
  json={
    "name": "welcome-bounce-back",
    "definition": {
      "nodes": [{"id": "n1", "type": "trigger"}],
      "edges": [],
    },
  })
flow_id = flow.json()["data"]["id"]  # flw_…
```

**Trigger a run** — a `trigger_data` payload optional, available to nodes as `{{…}}` template references:

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/flows/flw_9c2b1q/execute \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "trigger_data": { "phone": "+14155552671" } }'
```

```python Python theme={null}
run = requests.post(
  f"https://api.orbit.devotel.io/api/v1/flows/{flow_id}/execute",
  headers=headers,
  json={"trigger_data": {"phone": "+14155552671"}})
execution_id = run.json()["data"]["id"]  # accepted 202 — async run
```

**Inspect the execution** — one call returns the full per-node trace:

```bash cURL theme={null}
curl https://api.orbit.devotel.io/api/v1/flows/executions/fex_9c2b1q \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

```python Python theme={null}
execution = requests.get(
  "https://api.orbit.devotel.io/api/v1/flows/executions/fex_9c2b1q",
  headers=headers)
# data.status — running | waiting | completed | failed
# data.steps — every node's outcome, in order
```

The flow list/analytics reads (`GET /flows`, `GET /flows/executions/summary`) give the same history tail the dashboard renders. Create/trigger/history detail: [Flows API](/api-reference/endpoints/flows).

## See also

* [API integration](/guides/api-integration) — base URLs, sandbox, rate limits, idempotency
* [Error handling by example](/guides/error-handling-examples) — the error envelope and retry classes
* [Pagination](/guides/pagination) — cursor vs offset semantics
* [Starter examples](/guides/starter-examples) — runnable repos these recipes appear in
* [Loyalty program setup](/guides/loyalty-program) — earn rules, tiers, and the redemption flow behind Task 7
* [Connect HubSpot & Salesforce end to end](/guides/hubspot-salesforce-integration) — the full CRM loop behind Task 8
* [Team Chat](/guides/team-chat) — operator workflows behind Task 9
* [Voice API](/api-reference/endpoints/voice) — full create-body reference for Task 11
* [Numbers API](/api-reference/endpoints/numbers) — rent, release, and the porting lifecycle behind Task 12
* [Messaging API](/api-reference/endpoints/messaging) — template + WhatsApp send parameters for Task 13
* [Conversations API](/api-reference/endpoints/conversations) — copilot + inbox reads for Task 14
* [Segments API](/api-reference/endpoints/segments) — filter shapes and exports for Task 15
* [Flows API](/api-reference/endpoints/flows) — trigger + execution detail for Task 16
* [SD runs, no registry yet](/sdks/index) — when the SDKs publish, every curl here translates one-to-one
