> ## 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 recipes: third-party migrators — the per-provider concept template

> Runnable curl loops for the migration connectors every per-provider guide hangs off — resolve a Twilio Messaging Service SID into an Orbit messaging_service_id, port numbers with an Orbit-issued LoA URL, rewire status callbacks into Orbit message.delivered webhooks, flip TwiML into a published IVR flow, and swap the verify-codes round trip. Includes the 429/422 error-handling branches a cutover script needs.

# API recipes: third-party migrators

The five per-provider migration guides — [Twilio](/guides/migration-from-twilio), [Sinch](/guides/migration-from-sinch), [MessageBird](/guides/migration-from-messagebird), [Vonage](/guides/migration-from-vonage), [Infobip](/guides/migration-from-infobip) — each document the same five migrator loops with provider-specific names. This page is the template: the loops, the expected envelopes, and the branch you take on each outcome, with Twilio as the worked example. Write a connector for another provider (Telnyx, Klaviyo, Bandwidth) by substituting the source-side identifiers; the Orbit side of every loop is provider-agnostic. Base URL is `https://api.orbit.devotel.io/api/v1` throughout; authenticate with `X-API-Key` (test key `dv_test_sk_…` against the sandbox, live key for a cutover).

<Note>
  These are **tenant-owned controls**. Orbit supplies and enforces the migration surface; your organization names the source resource (a Messaging Service SID, a LoA file, a webhook URL), owns the source credentials, and makes the go-live call. These recipes are not legal advice.
</Note>

## 0. Automate the whole import instead

Five loops below are the code-level migrator; two higher-level surfaces wrap them for operators. Reach for the wizard when the source account's *configuration* (numbers, messaging services, templates, contacts) should land on Orbit without hand-mapping; reach for the loops when you need to drive the mapping from your own script or CI pipeline.

* **Assisted import wizard** — **Settings → Import → Twilio** (or Sinch / MessageBird / Vonage / Infobip) connects a read-only credential, shows a dry-run preview of what lands where, and commits with rollback. Neither the wizard nor the loops touch your live traffic; the existing provider keeps running until you cut over. Full walkthrough: [Assisted import wizard](/guides/assisted-import-wizard).
* **`devotel migrate` CLI** — the same wizard scriptable from a terminal or pipeline. Install with `npm install -g @devotel/cli`, `devotel auth login`, then `devotel migrate twilio --account-sid AC… --auth-token …` (dry run first; add `--run` to commit). Flag reference: [Devotel CLI guide](/guides/devotel-cli).

## 1. Resolve a Twilio Messaging Service SID into `messaging_service_id`

Twilio groups senders under a Messaging Service; Orbit models the same bundle as a messaging service — senders plus opt-out rules plus an inbound webhook — and a send carries it on `messaging_service_id`. The mapping is a one-line `metadata.external_id` on the Orbit service you create, so the old `MessagingServiceSid=SM…` your systems log stays resolvable.

**List the existing services** to find the one your script will tag (or to skip creation when a prior run already landed):

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

**Create the Orbit service** with the source id in `metadata.external_id` — the SID you used to pass to Twilio's `MessagingServiceSid` parameter:

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/messaging/services \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Transactional alerts (migrated)",
    "inbound_webhook_url": "https://your-app.example/hooks/orbit/inbound",
    "opt_out": {
      "keywords": ["STOP", "STOPALL", "UNSUBSCRIBE", "CANCEL", "END", "QUIT"],
      "reply": "You are unsubscribed from Acme alerts. Reply START to re-subscribe."
    },
    "metadata": {
      "external_id": "SM9f2a1c4b8d6e5f0a1b2c3d4e5f6a7b8c",
      "external_provider": "twilio"
    }
  }'
```

`201` returns the created service — `data.id` (`msg_svc_…`) is the `messaging_service_id` every subsequent send carries:

```json 201 theme={null}
{
  "data": {
    "id": "msg_svc_01HXV…",
    "name": "Transactional alerts (migrated)",
    "inbound_webhook_url": "https://your-app.example/hooks/orbit/inbound",
    "metadata": {
      "external_id": "SM9f2a1c4b8d6e5f0a1b2c3d4e5f6a7b8c",
      "external_provider": "twilio"
    }
  },
  "meta": { "request_id": "req_msg_svc_create", "timestamp": "2026-09-29T12:00:00.000Z" }
}
```

**Bind the numbers you ported** (loop 2) into the service so inbound SMS resolves to the right webhook:

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/messaging/services/msg_svc_01HXV…/phone-numbers \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "phone_number_id": "num_01J8Z9K" }'
```

**Send against it** the same way your Twilio send carried `MessagingServiceSid`:

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

Re-resolve the SID to the Orbit id at cutover time — never bake `msg_svc_…` into your app; read `GET /messaging/services` once at startup, filter on `metadata.external_id`, and cache the mapping. Canonical pages: [Messaging services console](/guides/messaging-services-console), [Migration from Twilio](/guides/migration-from-twilio#concept-mapping).

## 2. Port the numbers with an Orbit-issued LoA URL

A port request must carry a Letter of Authorization. Orbit accepts only a LoA URL it issued itself, so you upload the signed PDF first (the upload returns a short-lived signed URL), then pass that URL as `loaFileUrl`. A link to your own bucket — `https://storage.googleapis.com/your-loa-bucket/loa.pdf` — is rejected `422 LOA_URL_INVALID_ORIGIN` before the carrier sees it.

**Pre-check eligibility** (one call per batch; catches a number another provider owns):

```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": ["+14155551234", "+14155551235"] }'
```

**Upload the signed LoA PDF** — pass `ttl_ms` long enough for the carrier to fetch it (24 h here; the default is 1 h and a slow carrier will see an expired URL):

```bash cURL theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/files/upload?ttl_ms=86400000" \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -F "file=@./loa.pdf;type=application/pdf"
```

```json 200 theme={null}
{
  "data": {
    "id": "file_01H…",
    "url": "https://storage.googleapis.com/orbit-uploads/file_01H/loa.pdf?X-Goog-Signature=…",
    "expires_at": "2026-09-30T12:00:00.000Z"
  }
}
```

**Submit the port request** with `data.url` from the upload:

```bash cURL theme={null}
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": ["+14155551234", "+14155551235"],
    "currentCarrier": "Twilio",
    "country": "US",
    "authorizedSigner": "Ada Lovelace",
    "accountNumber": "123456789",
    "accountPin": "1234",
    "loaFileUrl": "https://storage.googleapis.com/orbit-uploads/file_01H/loa.pdf?X-Goog-Signature=…"
  }'
```

`201` returns `data.id` (`prt_…`). Ports complete with a carrier over 7–14 business days; track on `GET /numbers/porting` (one row per request) rather than polling `porting/check`. When the port lands, bind each new `numbers` row (it carries a `num_…` id) into the messaging service from loop 1. Canonical pages: [Port numbers](/guides/port-numbers), [First port walkthrough](/guides/number-porting-walkthrough-first-port).

## 3. Rewire status callbacks into Orbit webhooks

Twilio POSTs form-encoded status callbacks to a `statusCallback` URL per message; Orbit emits a JSON envelope (`message.delivered`, `message.failed`, `message.sent`) to endpoints you register once under **Settings → Webhooks**. Subscribe to the event types, then re-point your handler — never translate the old per-message `statusCallback` URL field-for-field.

**Register the endpoint** (once, tenant-wide):

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/webhooks \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example/hooks/orbit/status",
    "events": ["message.delivered", "message.failed", "message.sent"],
    "active": true
  }'
```

`201` returns `data.id` (`wh_…`) plus a one-time `data.secret` (`whsec_…`) — store it; the signature check needs it.

The source-side form-encoded body (`MessageSid=SM…&MessageStatus=delivered&…`) becomes a JSON event — verify `X-Orbit-Signature` (HMAC-SHA256 over the raw body) before trusting it:

```json message.delivered event theme={null}
{
  "id": "evt_msg_001",
  "type": "message.delivered",
  "created_at": "2026-09-29T12:00:00Z",
  "data": {
    "message_id": "msg_abc123",
    "channel": "sms",
    "from": "+14155551234",
    "to": "+14155552671",
    "status": "delivered"
  }
}
```

Decision table for the verifier:

| Source check | Orbit check |
| - | - |
| `twilio.validateRequest(authToken, signature, url, params)` | `verifyWebhookSignature(rawBody, signature, 'whsec_…')` from the Node SDK |
| `X-Twilio-Signature` header (HMAC-SHA1) | `X-Orbit-Signature` header (HMAC-SHA256); `X-Devotel-Signature` is legacy, not a build-target |
| Per-message `statusCallback` URL | One tenant-wide endpoint subscribed to `message.*` events |

Canonical pages: [Webhook events](/webhooks/events), [Verify webhook signatures](/guides/verify-webhook-signatures), [Webhook endpoint operations](/guides/webhook-endpoint-operations).

## 4. Flip the source IVR XML into a published flow

TwiML is a one-response XML document; Orbit models IVRs as **IVR flows** — a `{ nodes, edges }` graph the visual builder also saves. The mapping is a graph with exactly one `ivrStart` entry node, the prompts/transfers/hangups the XML declared, and no unreachable nodes. Create a draft, then publish it so inbound callers hit it.

**Create the draft flow:**

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/voice/ivr-flows \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Main menu (migrated from Twilio)",
    "active": true,
    "definition": {
      "nodes": [
        { "id": "start", "type": "ivrStart", "data": {} },
        {
          "id": "menu",
          "type": "menu",
          "data": {
            "prompt": "Press 1 for sales, 2 for support.",
            "menuOptions": [
              { "key": "1", "label": "Sales" },
              { "key": "2", "label": "Support" }
            ]
          }
        },
        { "id": "sales", "type": "transfer", "data": { "transferTo": "+14155550101" } },
        { "id": "support", "type": "transfer", "data": { "transferTo": "+14155550102" } }
      ],
      "edges": [
        { "id": "e1", "source": "start", "target": "menu" },
        { "id": "e2", "source": "menu", "target": "sales" },
        { "id": "e3", "source": "menu", "target": "support" }
      ]
    }
  }'
```

`201` returns the draft with `data.id` (`flw_…`). The graph is validated on save — exactly one `ivrStart`, at least one terminal (`transfer`, `hangup`, `voicemail`, `ringGroup`, …), no unreachable nodes, and DTMF keys restricted to `0`–`9`, `*`, `#`. `transfer.transferTo` must be a phone number or an on-net extension, never a raw `sip:` URI — outbound voice exits only via the Devotel softswitch.

**Publish** so inbound callers hit it (a draft is invisible to traffic):

```bash cURL theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/voice/ivr-flows/flw_01HXV…/publish \
  -H "X-API-Key: dv_test_sk_YOUR_KEY"
```

`200` returns the new live version number. Prefer to hand-author? The dashboard **Voice → IVR Builder** canvas posts the same flow and publishes from the UI. Canonical pages: [Build IVR flow](/guides/build-ivr-flow), [IVR builder canvas vs DSL walkthrough](/guides/ivr-builder-canvas-vs-dsl-walkthrough).

## 5. Swap the verify-codes round trip

The source's verify service (Twilio Verify, Sinch Verify, MessageBird Verify) generates, delivers, and expires an OTP; Orbit's Verify API does the same with a two-call round trip your backend drives. Never store or compare codes tenant-side.

**Send** — capture `data.verification_id`:

```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
  }'
```

**Check** — the user's answer plus the id from the 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"
  }'
```

`data.status` — `approved` is the only pass. `failed` means the code didn't match; retry with the same `verification_id` until `attempts_remaining` hits 0, then send a fresh one. `expired` means the TTL lapsed — send again. In sandbox, the test key simulates delivery; read the expected code from the verification's dashboard row. Canonical pages: [Verify in 30 minutes](/guides/verify-in-30-min), [Verify API](/api-reference/endpoints/verify).

## Errors as recipes (the 429 / 422 branches a cutover script needs)

Branch on `error.code` — never on `message` text, which can drift.

| Signal | `error.code` | HTTP | Branch |
| - | - | - | - |
| Send rate over the key's limit | `RATE_LIMITED` | 429 | Read `error.retry_after` (also the `Retry-After` header) and wait that many seconds — never blind-exponential-backoff inside a named window; retry with the same `Idempotency-Key` so the retry can't double-send. |
| Invalid destination on a send | `INVALID_PHONE_NUMBER` | 422 | Normalize to E.164, retry the same body — same 422 if you don't. |
| LoA URL not issued by Orbit | `LOA_URL_INVALID_ORIGIN` | 422 | Loop 2: pass `files/upload`'s `data.url`, not your own bucket link. |
| IVR graph invalid (unreachable node, no terminal, bad DTMF key) | `VALIDATION_ERROR` | 422 | Loop 4: fix the named field — the message names the node id; never retry the same definition. |
| Messaging-service binding on a number that isn't yours | `NUMBER_NOT_OWNED` | 422 | Loop 1: port it first (loop 2), then bind. |
| Verify check on an expired id | *(status `expired` on `/verify/check`, not a thrown error)* | 200 | Loop 5: send a fresh code; never loop on the same id. |
| Key issue (wrong prefix, revoked) | `INVALID_API_KEY` | 401 | Check `dv_test_sk_` vs `dv_live_sk_` and the key's state in **Settings → API Keys**. |

A customer-side retry wrapper honors the server-provided wait first, then falls back to a capped exponential backoff — the full pattern is at [API error handling by example](/guides/error-handling-examples#b-rate_limited-429-read-retry-after-back-off-exponentially).

## See also

* [Assisted import wizard](/guides/assisted-import-wizard) — the dashboard wizard and `devotel migrate` CLI these loops wrap for an operator
* [Devotel CLI guide](/guides/devotel-cli) — the scriptable loop (`devotel migrate twilio …`)
* [Migration from Twilio](/guides/migration-from-twilio) — the provider-specific walkthrough these loops generalize
* [Migration from Sinch](/guides/migration-from-sinch) / [MessageBird](/guides/migration-from-messagebird) / [Vonage](/guides/migration-from-vonage) / [Infobip](/guides/migration-from-infobip) — the siblings these loops re-express
* [Migration playbook hub](/guides/migration-playbook-hub) — the read that links all five providers
* [Port numbers](/guides/port-numbers) — loop 2's full walkthrough (port-in plus port-out)
* [Webhook events](/webhooks/events) — loop 3's event catalog
* [Build IVR flow](/guides/build-ivr-flow) — loop 4's full graph-builder walkthrough
* [Verify in 30 minutes](/guides/verify-in-30-min) — loop 5's onboarding walkthrough
* [API error handling by example](/guides/error-handling-examples) — the `429`/`422` branches above expanded
* [API recipes: operations endpoints](/guides/api-recipes/operations) — the sibling cookbook for operational surfaces
