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

# Port numbers out safely: PIN protection, FOC tracking, and cancellation

> Move a number off Devotel Orbit to another carrier the safe way: gate on a portable status, set a port-out PIN before anyone submits, dispatch single or bulk port-outs, track the FOC date and status via webhooks, cancel inside the window, and handle carriers with no self-serve adapter.

# Port out safely

Port-outs are irreversible at carrier level after the FOC date lands, and the attack that matters for the operator side is unauthorized — a hijacked credential submits a port to a carrier the attacker controls before anyone notices the number moved. This guide covers the runbook your team runs to move numbers off Orbit on purpose, and the PIN control you switch on beforehand so a credential alone is not enough.

Reads (`list`, `get`) need the `numbers:read` scope; writes (submit, refresh, cancel, PIN set/remove) need `numbers:write` and an `owner`, `admin`, or `developer` role. The API reference is at [Numbers](/api-reference/numbers) and the port-IN counterpart at [Port a number end-to-end](/guides/port-numbers).

## 1. Treat port-outs as gated work

Two failure classes drive the whole design:

* **Port hijack** — a stolen API key or session submits a port-out to a carrier the attacker controls. Because the losing carrier treats your submission as authorized, the number is live elsewhere before your ops team reacts. Orbit's defense is the per-number port-out PIN (below): without the PIN, credentials alone cannot dispatch.
* **Cancel-window misses** — after the winning (gaining) carrier issues a Firm Order Commitment (FOC) date, the losing side may still honor cancellations, but only until the cutover. Watch the `number.port_out.requested` webhook and the FOC date so the "do we actually want to cancel this" decision happens inside the window, not after it.

Do not treat the port-out submit as a bookkeeping call. Run it through the same change-control your deploys get: pre-flight the status, set the PIN before traffic, sign off on the OCN, and track the order to completion or cancellation.

## 2. Pre-flight: portable status, then PIN protection

Two gates, in order. First, the number must be in a portable state — port-outs from a number that is already releasing, suspended, or mid-port return `409` and nothing dispatches. Second, put a PIN on the number before any port-out is authorized. Even when your team expects to move the number tomorrow, set the PIN today: it is the control that keeps a credential compromise on an unrelated surface from draining your inventory.

Check the status first with `GET /numbers/:id` — a number in `released`, `suspended`, `parked`, or `port_out_pending` rejects `POST /numbers/:id/port-out` with `409 CONFLICT`.

### Enable PIN protection

```bash theme={null}
# POST /:id/port-out-protection — set the port-out PIN (4-32 chars)
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/num_9f4a21/port-out-protection \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "pin": "XF-2841-QS" }'
```

```json theme={null}
{ "data": { "enabled": true, "set_at": "2026-08-26T14:05:11.000Z" } }
```

The plaintext PIN is stored only in one-way scrypt form and is NEVER returned by any read. `GET` returns the same shape:

```bash theme={null}
curl https://api.orbit.devotel.io/api/v1/numbers/num_9f4a21/port-out-protection \
  -H "X-API-Key: dv_live_sk_..."
```

```json theme={null}
{ "data": { "enabled": true, "set_at": "2026-08-26T14:05:11.000Z" } }
```

Record the PIN in your secret store at set-time. There is no recovery path — if the PIN is lost, rotate by POSTing a new one (the old hash is replaced, and the cleared value is unrecoverable), or `DELETE` to disable protection outright.

```bash theme={null}
# Rotate: POST replaces the PIN with a fresh value
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/num_9f4a21/port-out-protection \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{ "pin": "new-pin-here" }'

# Disable protection entirely (cleared PIN is unrecoverable)
curl -X DELETE https://api.orbit.devotel.io/api/v1/numbers/num_9f4a21/port-out-protection \
  -H "X-API-Key: dv_live_sk_..."
```

Set a PIN on every production number before a single port-out is run. Any port-out attempt on a protected number without a matching PIN returns `401 PORT_OUT_PIN_MISMATCH` before the carrier dispatch fires — so the carrier portal never sees the unauthorized attempt.

## 3. Submit a single port-out

With pre-flight clean and the PIN set, submit the port-out. The body names the losing-carrier account (proof of ownership the current carrier validates), the authorized signer, and the winning carrier's OCN:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/num_9f4a21/port-out \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "losing_carrier_account_number": "ACCT-998877",
    "authorized_person": "Jane Doe",
    "target_carrier_ocn": "6117",
    "port_out_pin": "XF-2841-QS"
  }'
```

On success the carrier order is already placed and the response carries the carrier order id plus any FOC date the carrier issued immediately:

```json theme={null}
{
  "data": {
    "id": "num_9f4a21",
    "phoneNumber": "+14155551234",
    "provider": "telnyx",
    "providerOrderId": "0e2f8c33-...",
    "providerStatus": "pending",
    "focDate": null,
    "portStatus": "port_out_pending"
  }
}
```

`focDate` is frequently `null` on first submission — the carrier schedules it asynchronously and you pick it up on refresh (below) or on the webhook. The number's status flips to `port_out_pending` so the dashboard shows a distinct badge and a second submission is refused.

Error cases you will see:

* `401 PORT_OUT_PIN_MISMATCH` — PIN protection is enabled and the PIN was missing or wrong. The carrier is not contacted. Fix the PIN and retry.
* `409 CONFLICT` — the number is in a non-portable status (`port_out_pending`, `released`, `suspended`, `parked`) or another port-out for the same number is already in flight. The lock is an atomic claim — a second submission races to `409`, and even the lock auto-expires (30s TTL) so a dead worker cannot strand the number.
* `422 PORT_OUT_NOT_SUPPORTED` — this number's carrier has no self-serve port-out adapter (see availability caveat below). Dispatch manually in the carrier portal.
* `502 CARRIER_PORT_OUT_FAILED` — the carrier rejected or errored. The number's local status is rolled back to its prior state so nothing is stranded in a phantom pending.

## 4. Batch flow: submit, list, fetch, cancel

For a multi-number move (an estate leaving, or a subset), submit up to 50 E.164 numbers in one call. All numbers must be hosted on the same carrier — mixed-carrier batches reject `422`.

```bash theme={null}
# POST /porting/out — up to 50 numbers, one destination carrier
curl -X POST https://api.orbit.devotel.io/api/v1/numbers/porting/out \
  -H "X-API-Key: dv_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "numbers": ["+14155551234", "+14155555678"],
    "destinationCarrier": "6117",
    "newOwnerInfo": "Jane Doe (account: ACCT-998877)",
    "port_out_pin": "XF-2841-QS"
  }'
```

If any DID in the batch has PIN protection enabled, the `port_out_pin` is required for the whole batch and must match every protected number — a mismatched PIN rejects the entire request before any wireless dispatch.

Track the batch with the list and per-id fetch:

```bash theme={null}
# List every port-out your org has submitted
curl https://api.orbit.devotel.io/api/v1/numbers/porting/out \
  -H "X-API-Key: dv_live_sk_..."

# Fetch one
curl https://api.orbit.devotel.io/api/v1/numbers/porting/out/por_abc123 \
  -H "X-API-Key: dv_live_sk_..."
```

Each row is:

```json theme={null}
{
  "data": {
    "id": "por_abc123",
    "numbers": ["+14155551234", "+14155555678"],
    "destinationCarrier": "6117",
    "status": "submitted",
    "submittedAt": "2026-08-26T14:11:45.000Z",
    "provider": "telnyx",
    "providerOrderId": "0e2f8c33-...",
    "focDate": null
  }
}
```

Re-poll the carrier for a fresh status and the FOC date once the carrier has had time to schedule:

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

Cancel while the carrier still allows it:

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

Cancellation is honored only while the carrier has not locked the order — after the FOC cutover the winning carrier owns the number and `DELETE` no longer applies. Treat the cancel window as real: a cancel decision delayed past FOC expiry means the number is gone.

## 5. Operate on webhooks

Subscribe to the port-out events rather than polling lists. Register your endpoint once (see [Webhooks](/webhooks/overview) and [Webhook Security](/webhooks/security) for signature verification) and push these two:

| Event                       | Fires when                                                             | Use it to                                                                                                                                                                                                                                     |
| --------------------------- | ---------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `number.port_out.requested` | A port-out was submitted and accepted by the carrier (single or bulk). | Alert your ops channel the moment a number starts moving — a PIN-mismatch rejection never reaches the carrier, so this event firing means the request passed the PIN gate. Record the carrier order id and start the cancel-window countdown. |
| `number.port_out.cancelled` | A port-out request was cancelled before cutover.                       | Remove the number from your "leaving" board, re-enable routing expectations, and close the migration ledger entry.                                                                                                                            |

The full catalog is at [Webhook Events](/webhooks/events).

## 6. Carrier availability caveat

Self-serve port-out (`POST /numbers/:id/port-out` and the bulk path) dispatches through the carrier's own API. Not every carrier your numbers ride on exposes a port-out adapter — when none exists, `POST /numbers/:id/port-out` returns `422 PORT_OUT_NOT_SUPPORTED` and nothing is dispatched. In that case:

1. Go to the carrier's own portal and submit the port-out there directly.
2. Record the carrier-issued order id in your own migration ledger — the carrier portal is authoritative for those numbers.
3. Mark the number in Orbit as leaving via your ops process so dashboard state matches reality (the admin port-out surface handles the platform-side bookkeeping).

The single-DID route returns the same `422` with the carrier name in the message, so you can detect and route around it per-number.

## 7. Locking behavior: one port-out per number, atomically

The port-out submit is serialized per number. A second submission while one is in flight — whether from a retry, a race between two operators, or an attacker's parallel attempt — returns `409` and never reaches the carrier:

* The claim is atomic at the database layer: only one request flips the number from its portable status to `port_out_pending`; the loser sees zero rows and aborts before any carrier call.
* A Redis lock serializes per-number submissions across API replicas; the TTL auto-expires after 30 seconds so a crashed worker cannot hold the number hostage.
* The carrier layer deduplicates via an idempotency key derived from the DID id, so even a retry that somehow re-entered after a release does not open a second carrier order.

Operationally: build your caller with retry-on-`409` only after checking the list endpoint — a `409` almost always means "someone already submitted this," not "submit again louder."

## See Also

* [Number Porting](/guides/port-numbers) — the symmetric port-IN runbook.
* [Number Lifecycle](/numbers/lifecycle) — release, auto-renew, and scheduled-release for numbers staying on Orbit.
* [Number Porting endpoints](/numbers/porting) — the shorter per-endpoint reference.
* [Numbers API Reference](/api-reference/numbers) — full request/response schemas.
* [Webhook Events](/webhooks/events) — the complete event catalog.
