Skip to main content

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 and the port-IN counterpart at Port a number end-to-end.

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

The plaintext PIN is stored only in one-way scrypt form and is NEVER returned by any read. GET returns the same shape:
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.
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:
On success the carrier order is already placed and the response carries the carrier order id plus any FOC date the carrier issued immediately:
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.
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:
Each row is:
Re-poll the carrier for a fresh status and the FOC date once the carrier has had time to schedule:
Cancel while the carrier still allows it:
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 and Webhook Security for signature verification) and push these two: The full catalog is at Webhook 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