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

# Porting numbers end-to-end: preflight, LOA, FOC, and bulk CSV

> Run a number port from first check to post-completion: gather CSR data, gate on preflight, submit, checkpoint a wizard draft, drive the LOA lifecycle, handle supplements and FOC, wire porting webhooks, and migrate up to 1000 DIDs with one CSV.

# Port a number end-to-end

Porting is the highest-stakes move a customer makes on Orbit: get the CSR data wrong and the losing carrier rejects weeks in, and you restart the clock. This guide walks one complete port — for a single DID or a 50-1000-DID estate — from the first portability check through the LOA signature, the FOC timeline, webhook-driven ops, and post-port lifecycle. Build it once and every migration looks the same.

Reads (`list`, `get`, `timeline`, `check`, `pre-validate`) need the `numbers:read` scope; writes (`create`, LOA upload/sign/submit, `supplement`, `cancel`) need `numbers:write` and an `owner`, `admin`, or `developer` role. The API reference is at [Numbers](/api-reference/numbers); the shorter endpoint sequence page is at [Number Porting](/numbers/porting).

## 1. Prep the data

Before any API call, pull a **Customer Service Record (CSR)** from the losing carrier — a bill or the carrier's own LNP record that shows the exact legal entity name, account number, billing telephone number (BTN for multi-line), service address, and any transfer PIN. Rejection codes like `ADDRESS_MISMATCH` and `NAME_MISMATCH` almost always trace back to a guessed, not copied, CSR field.

Build the E.164 list of numbers to move, and note these country rules up front:

* **US / CA** numbers route to a live synchronous portability check (Telnyx). Everywhere else routes to DIDWW, which has no synchronous check — the verdict comes back as `check-not-supported` and you verify eligibility manually.
* **Toll-free numbers do not port carrier-to-carrier.** They move by changing the Responsible Organization (RespOrg) via Somos; submit that at `POST /numbers/porting/toll-free/resporg` instead of the LNP flow.
* If the number is SMS-enabled on the losing carrier, call that out on the LOA — the response `splittable: true` tells you the carrier may port voice but leave SMS behind.

## 2. Preflight — gate before you submit

Run preflight on every number. It is read-only, creates no order, and catches the rejections that would otherwise cost you the 2-7 business-day submission window.

```bash theme={null}
# Single number — one live carrier call
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/porting/check \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "phoneNumber": "+14155551234", "currentCarrier": "Acme Telecom" }'
```

```json theme={null}
{
  "data": {
    "eligible": true,
    "reasons": [],
    "splittable": false,
    "check_status": "checked",
    "provider": "telnyx"
  }
}
```

Read the verdict as:

* `eligible: false` — do not submit; `reasons` names the blockers (e.g. rate-center exclusion). `fatal` rejections must stay at the losing carrier.
* `check_status: "check-not-supported"` — DIDWW territory; verify eligibility manually before submitting.
* `check_status: "check-skipped"` — the carrier call failed and Orbit passed optimistically; never gate on this. Re-run or verify manually.
* `splittable: true` — call SMS out on the LOA explicitly.

For a full estate, batch it (up to 1000 distinct numbers per call, chunked upstream per provider):

```bash theme={null}
# Bulk check — one call per CSV, not one call per DID
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/porting/check/bulk \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "phoneNumbers": ["+14155551234", "+14155555678"], "currentCarrier": "Acme Telecom" }'
```

The combined gate — `pre-validate` — runs the same portability check **and** statically lints the CSR fields you will send, catching the omission/mismatch class of rejections (ADM/address, name, BTN) before the order exists:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/porting/pre-validate \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "numbers": ["+14155551234"],
    "currentCarrier": "Acme Telecom",
    "accountNumber": "998877",
    "authorizedSigner": "Jane Doe",
    "serviceAddress": {
      "line1": "1 Market St",
      "city": "San Francisco",
      "state": "CA",
      "postalCode": "94105",
      "countryCode": "US"
    }
  }'
```

```json theme={null}
{
  "data": {
    "ready": true,
    "portability": {
      "results": [
        {
          "phoneNumber": "+14155551234",
          "eligible": true,
          "reasons": [],
          "splittable": false,
          "check_status": "checked",
          "provider": "telnyx",
          "country": "US"
        }
      ],
      "summary": { "total": 1, "eligible": 1, "ineligible": 0 }
    },
    "csr": {
      "ready": true,
      "issues": []
    }
  }
}
```

Gate on `ready: true` — every number portable AND no blocker-severity CSR issue. `check` is rate-limited to 20 req/min, `check/bulk` and `pre-validate` to 5 req/min per auth context.

## 3. Submit the port

Once `ready`, submit. `numbers` accepts a single E.164 string or an array for a multi-line port under one account. There are three ways a create can leave your system, and the choice matters for how the request is tracked afterwards:

* **Full dispatch (default)** — send `country` and no `draft` flag. Orbit opens the carrier order immediately (Telnyx for US/CA, DIDWW elsewhere) and the row returns with a `provider` set. This is the single-shot path the curl below takes.
* **Wizard checkpoint — `draft: true`** — store the request locally and deliberately skip carrier dispatch. The row is stamped `staging: "draft"`; nothing reaches the losing carrier until the LOA is uploaded and signed on the returned row. This is the semantic the tenant-facing port-in wizard's first step uses, and any flow you build that needs to checkpoint numbers and CSR details before a signed LOA exists.
* **Ops handover — omit `country`** — also stores the request with no carrier dispatch, but it means "ops hand-processes this port": no provider is ever selected, `refresh` has nothing to poll (it returns `422`), and the request stays in manual mode for its whole life. Use it when a human on your side (or ours) drives the port out-of-band.

Draft and ops-handover both skip dispatch at create time, but they are not interchangeable: a draft is a wizard checkpoint waiting on its own LOA and is meant to be resumed (below); an omitted-`country` row is a permanent manual-mode request with no resume semantics. Picking the wrong one either strands a wizard mid-flow or sends ops a request that was never meant for hand-processing.

```bash theme={null}
# Single-shot — Orbit dispatches to the carrier immediately
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/porting \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "numbers": ["+14155551234"],
    "currentCarrier": "Acme Telecom",
    "country": "US",
    "accountNumber": "998877",
    "authorizedSigner": "Jane Doe",
    "serviceAddress": {
      "line1": "1 Market St",
      "city": "San Francisco",
      "state": "CA",
      "postalCode": "94105",
      "countryCode": "US"
    }
  }'
```

```json theme={null}
{
  "data": {
    "id": "port_abc123",
    "numbers": ["+14155551234"],
    "status": "submitted",
    "loaSignatureStatus": "draft",
    "provider": "telnyx",
    "focDate": null,
    "rejectionReason": null
  }
}
```

### Wizard draft intake

The hosted port-in wizard — the **Single DID** form on the dashboard's [Number Porting](/numbers/porting) page — checkpoints the request before an LOA exists. Building the same flow yourself is one flag:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/porting \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "numbers": ["+14155551234"],
    "currentCarrier": "Acme Telecom",
    "country": "US",
    "accountNumber": "998877",
    "authorizedSigner": "Jane Doe",
    "serviceAddress": {
      "line1": "1 Market St",
      "city": "San Francisco",
      "state": "CA",
      "postalCode": "94105",
      "countryCode": "US"
    },
    "draft": true
  }'
```

```json theme={null}
{
  "data": {
    "id": "port_draft456",
    "numbers": ["+14155551234"],
    "status": "submitted",
    "staging": "draft",
    "provider": null,
    "focDate": null
  }
}
```

The walk-through the wizard drives (and the order your own flow should follow):

1. **Enter the numbers** — one E.164 per line; the wizard's single-DID form validates each line on entry. Run the [preflight gate](#2-preflight-gate-before-you-submit) first; the dashboard offers the same eligibility check inline and blocks submission on a negative verdict.
2. **Check the carrier** — pick the losing carrier and fill the CSR fields (account number, PIN for US wireless, authorized signer, service address). These must match the losing carrier's records exactly or the port rejects days later.
3. **Save the draft** — `POST /numbers/porting` with `draft: true`. The row is stored locally, `staging: "draft"`, no carrier call. Re-creating with the same number set is idempotent: instead of a second row you get the original draft back, so a wizard step-forward or a page refresh never forks the checkpoint.
4. **Upload the LOA in-platform** — `POST /numbers/porting/port_draft456/loa` with the PDF/image (see [LOA lifecycle](#4-loa-lifecycle-upload-sign-submit)).
5. **Sign** — `POST /numbers/porting/port_draft456/loa/sign` with the signer name, email, and acknowledgement.
6. **Submit** — `POST /numbers/porting/port_draft456/loa/submit` forwards the signed LOA. From here the [timeline](#6-track-timeline-stages-and-foc) drives to FOC the same as a single-shot port.
7. **A draft has no carrier-side state** — the background status poller skips it and `GET /porting/:id/refresh` refuses it (`422`, nothing tracked at any carrier yet), so a draft can never be accidentally dispatched by polling. It only moves when you upload and sign the LOA.

### Resume and submit from a draft

A draft never dispatches on its own. Turning it into a live port is the LOA lifecycle on the returned id — there is no separate "submit the draft" endpoint, and nothing to re-POST on the create route:

1. `POST /numbers/porting/:id/loa` — attach the document; `loaSignatureStatus` becomes `draft`.
2. `POST /numbers/porting/:id/loa/sign` — record the signer acknowledgement; status becomes `signed`. Submitting before this step, or signing twice, returns `422`.
3. `POST /numbers/porting/:id/loa/submit` — the only `signed`-state endpoint; anything else returns `422`. The signed LOA attaches to the carrier order when one already exists (the normal case for port ranges that were dispatched separately).

Validate the LOA upgrade path the same way you would a fresh create — re-run `pre-validate` before step 1 if the CSR data was gathered more than a few days ago, because a draft that sat through a billing-cycle change can arrive with stale account details.

**Do not park a request in draft.** No FOC date is ever issued for an undispatched row: the losing carrier only starts its 7–14 business-day review once it holds an order plus a signed LOA, and a draft has neither. A wizard that stops after step 3 produces a checkpoint that ages silently while every stakeholder assumes the port is in flight — the dashboard wizard flags exactly this state as "draft — LOA pending". If the request is genuinely blocked (signer unavailable, CSR unknown), cancel the draft and re-create it when ready rather than letting it sit.

## 4. LOA lifecycle — upload, sign, submit

The Letter of Authorization proves you are authorized to move the number. Orbit exposes a dedicated three-step lifecycle rather than accepting a bare file URL, and each step transitions `loaSignatureStatus`:

```bash theme={null}
# 1. Upload the LOA artefact (PDF / JPEG / PNG / HEIC, up to 10MB)
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/porting/port_abc123/loa \
  -H "X-API-Key: dv_live_sk_..." \
  -F "file=@loa.pdf"
# status -> draft

# 2. Record the in-platform signature acknowledgement
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/porting/port_abc123/loa/sign \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "signerName": "Jane Doe",
    "signerEmail": "jane@acme.com",
    "acknowledgement": "I am authorized to request this number be ported to Orbit and the information above is accurate."
  }'
# status -> signed

# 3. Forward the signed LOA to the losing carrier
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/porting/port_abc123/loa/submit \
  -H "X-API-Key: dv_live_sk_..."
# status -> submitted
```

The state machine is one-directional and the irreversibility is deliberate: once the LOA is `signed` or `submitted`, re-uploading a replacement is refused (cancel the port and resubmit if the wrong document went out). Signing twice returns `422`, not a silent merge. Make the LOA a real PDF at ≥200 dpi with the signature dated within 30 days, every number in the port range listed, and the authorized-signer name + title legible — `LOA_INVALID` rejections cluster here. The acknowledgment text and signer details are your organization's own authorization record; Orbit conveys it to the carrier and stores it for your audit trail — it does not certify authorization on your behalf.

## 5. Supplier response loop — supplements and refresh

If the losing carrier kicks back a supplement request (a minor data correction — an address line, a suite number), respond **within the 7-day amend window** so the FOC clock does not restart:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/porting/port_abc123/supplement \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "serviceAddress": { "line2": "Suite 200", "countryCode": "US" } }'
```

Supply at least one of an updated LOA URL, CSR URL, billing name/address, account number, PIN, or narrative. The supplement endpoint returns `422` when the request is not in a rejected or supplement-submitted state, and `409` when the carrier refuses (amend window expired or order already completed).

Poll the provider for a fresh carrier decision with:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/porting/port_abc123/refresh \
  -H "X-API-Key: dv_live_sk_..."
```

Manual-mode requests (submitted without `country`) return `422` here — there is no carrier order to refresh. Cancel outright with `DELETE /porting/:id`; that is only possible while in `submitted` or `reviewing` — it returns `409` once the losing carrier has approved.

## 6. Track — timeline stages and FOC

`GET /porting/:id/timeline` expands the flat status enum into a structured per-stage view — a pure read on the stored row, no carrier call:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/numbers/porting/port_abc123/timeline" \
  -H "X-API-Key: dv_live_sk_..."
```

```json theme={null}
{
  "data": {
    "portingId": "port_abc123",
    "status": "reviewing",
    "stages": [
      { "stage": "submitted", "state": "completed", "label": "Submitted", "enteredAt": "2026-08-01T10:00:00.000Z" },
      { "stage": "validating_loa", "state": "completed", "label": "Validating LoA", "enteredAt": "2026-08-01T10:00:00.000Z" },
      { "stage": "carrier_review", "state": "current", "label": "Losing carrier review", "enteredAt": "2026-08-01T10:00:00.000Z" },
      { "stage": "foc_assigned", "state": "pending", "label": "FOC date assigned" },
      { "stage": "foc_scheduled", "state": "pending", "label": "Scheduled for FOC" },
      { "stage": "completed", "state": "pending", "label": "Port complete" }
    ],
    "elapsedDays": 4,
    "focDate": null
  }
}
```

Status maps to stages: `submitted → submitted`, `reviewing → carrier_review` (LOA already validated), `approved → foc_assigned` (or `foc_scheduled` when `focDate` is set), `completed → completed`, `rejected →` snapshot at whichever stage it died at. `focDate` is echoed once the losing carrier assigns one. On a rejected port, stages after the current one come back `skipped` so a status dashboard does not show a ghost-future.

When `status` is `rejected`, the response carries a `rejection` object that translates the carrier's opaque reject code (Telnyx ADM/NMM, Bandwidth 5005/5006, DIDWW `ADDRESS_DOES_NOT_MATCH`, numeric NIIF codes, and more) into a plain-English `summary` and `recommendedAction`, tagged with a `severity`:

* `operator_fixable` — fix your own data and resubmit inside the amend window (address/name/PIN/BTN/supplement issues).
* `needs_carrier` — contact the losing carrier to resolve (unrecognized account number, conflicting order).
* `fatal` — the number cannot be ported at all (`NUMBER_NOT_PORTABLE`); keep it at the losing carrier.

Unknown codes fall through with the raw carrier text preserved rather than being hidden, so an operator can always escalate with the exact reason.

## 7. Webhook-driven ops — build a status dashboard

Subscribe to these events instead of polling `timeline` on a loop — register once, and Orbit pushes each transition to your endpoint (see [Webhooks overview](/webhooks/overview) and [Webhook Security](/webhooks/security) for signature verification):

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/webhooks \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhooks/orbit",
    "events": [
      "number.ported",
      "porting.request.loa_signed",
      "porting.request.supplement_submitted",
      "porting.request.manual_review_required",
      "porting.request.cancelled"
    ]
  }'
```

| Event                                    | Fires when                                                           | Use it to                                                                                                |
| ---------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `number.ported`                          | The port-in completed and the number is live on your account.        | Cut routing, send the "you're live" notification, kick off post-port provisioning. Emitted exactly once. |
| `porting.request.loa_signed`             | The in-platform LOA signature was captured.                          | Advance the internal checklist; queue the LOA submit step.                                               |
| `porting.request.supplement_submitted`   | You submitted a carrier-requested supplement.                        | Reset the amend-window countdown; alert the operator who owns the file.                                  |
| `porting.request.manual_review_required` | The port fell back to manual review (ops processes the LOA by hand). | Flag for human follow-up; do not auto-retry.                                                             |
| `porting.request.cancelled`              | You or an admin cancelled the request.                               | Remove from the active board; record the reason for the migration ledger.                                |

Compose these into a status dashboard by joining the event stream with the timeline endpoint — events drive the realtime updates, and a daily `GET /porting` + `timeline` sweep reconciles anything missed (deliveries are at-least-once, so dedupe on the event `id`). That combination gives you a live board of "where is every port stuck and what do I do about it" without a carrier call per page load.

**Dashboard vs. API path:** the dashboard renders the same timeline and rejection explanation in the porting wizard, and bulk CSV upload has a UI twin — use it for a one-off operator-led migration. Use the API when you need the gate (`pre-validate`) in a pipeline, the event stream wired into your own ops tooling, or a 500-DID estate you cannot click through. Both paths read the same underlying request, so you can mix them — submit by API, watch in the dashboard.

## 8. Bulk CSV migration — 50-1000 DIDs at once

Migrating a whole DID estate as one handoff does not fit the one-number-at-a-time form. Upload a CSV instead:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/port-in/bulk-csv \
  -H "X-API-Key: dv_live_sk_..." \
  -F "file=@port-in-batch.csv"
```

The CSV must have a header row with exactly these columns (order does not matter, header matching is case-insensitive):

```
phone_number, current_carrier_account_number, current_carrier_name, billing_name, billing_address
```

A complete example row set for a three-line migration:

```csv theme={null}
phone_number,current_carrier_account_number,current_carrier_name,billing_name,billing_address
+14155551234,998877,Acme Telecom,Jane Doe,"1 Market St, San Francisco, CA 94105, US"
+14155555678,998877,Acme Telecom,Jane Doe,"1 Market St, San Francisco, CA 94105, US"
+14155559901,112233,Acme Telecom,Jane Doe,"410 Pine St, Suite 200, Seattle, WA 98101, US"
```

Rules and caps:

* Quote any field containing a comma (`billing_address` almost always needs it). A header missing a required column aborts the whole upload with `422` and names the missing columns.
* Each valid row becomes its own porting request (`status: submitted`, stored for manual carrier dispatch) — bulk import does not fire 50-1000 sequential carrier calls inside one request. Your team attaches the LOA and country code per-row afterwards through the normal dashboard flow.
* 2MB file size (roughly 12k rows), 1000 rows per request, 30-second processing budget, rate-limited to 2 requests per minute per organization. Split a larger estate across uploads rather than retrying a timed-out one.
  Duplicate submissions against an active in-flight request are skipped; the response reports both sides of the batch:

```json theme={null}
{
  "data": {
    "request_ids": ["port_a1b2c3", "port_d4e5f6"],
    "validation_errors": [
      { "row": 14, "field": "phone_number", "error": "Not a valid E.164 number" }
    ],
    "duplicates_skipped": 2,
    "summary": {
      "total_rows": 3,
      "created_count": 2,
      "duplicate_count": 2,
      "validation_error_count": 1,
      "overall_result": "partial"
    }
  }
}
```

Run the [preflight bulk check](#2-preflight-gate-before-you-submit) on the same number list before you upload — it is far cheaper to drop a non-portable DID from the CSV than to rear-face a rejection days later.

## 9. After the port — lifecycle, hosted messaging, RespOrg

Once `number.ported` fires:

* **The number enters the normal lifecycle** — auto-renew, scheduled release, reassignment, and reclaim all apply to ported numbers the same as purchased ones. See [Number Lifecycle](/numbers/lifecycle).
* **SMS on a ported DID** behaves exactly like a purchased DID on Orbit — no extra step.
* **Hosted messaging / text-enable without porting** — if you deliberately kept voice with the losing carrier (or the number was not voice-portable), you can still text-enable it via `POST /numbers/hosted-messaging`. That is a carrier-side SMS route with its own LOA, not a port.
* **Toll-free** post-port administration lives under the RespOrg flow (`/porting/toll-free/resporg` — you re-home the number, not port it). Route changes go through Somos out-of-band.

Checklist for the next migration: build the E.164 list → pull the CSR → run `pre-validate` to `ready: true` → submit (or checkpoint with `draft: true` when the LOA is still outstanding) → upload/sign/submit LOA → subscribe the five events → track on the timeline → react to `number.ported` → hand off to lifecycle. That is the whole loop.

## See Also

* [Number Porting endpoint sequence](/numbers/porting) — the shorter endpoint-by-endpoint page.
* [Number Lifecycle](/numbers/lifecycle) — auto-renew, release, reassignment for completed ports.
* [Numbers API Reference](/api-reference/numbers) — full request/response schemas including RespOrg and hosted messaging.
* [Webhook Events](/webhooks/events) — complete event catalog.
