Port-in orders
A port-in order transfers numbers from their current carrier onto the platform. Orders are asynchronous: after submission, progress depends on the losing carrier and can take from hours (US FastPort-style) to weeks (international/local processes).
Lifecycle
draft ──submit──▶ in_review ──▶ submitted ──▶ in_process ──▶ foc_scheduled ──▶ activating ──▶ completed
│ │ │ completed_partial
└───────── rejection ◀───────┘ fix requirements, resubmit ──▶ in_review
| Status | Meaning |
|---|---|
draft |
Created; fulfill the requirements, then submit |
in_review |
Submitted to us — our porting desk verifies every field and document before anything reaches the losing carrier |
submitted |
Review passed; sent to the provider, being validated on their side |
in_process |
The losing carrier is processing the port |
rejection |
Something was refused but is fixable — check the requirements' exception_reason, fix, resubmit |
foc_scheduled |
The port date (FOC — Firm Order Commitment) is confirmed; see foc_actual |
activating |
Numbers are being activated on the platform |
completed / completed_partial |
Done — numbers are live (partial: some numbers failed) |
cancel_pending |
Cancellation requested, awaiting provider confirmation |
cancelled |
Terminal. closed_by tells you who ended it: customer (you cancelled), provider (final refusal by the carrier), or ops |
The review step is why porting through the platform is safe: a human checks
your submission and rejects individual fields with a reason (a
porting_order.requirement_updated webhook plus a rejection status)
instead of letting the carrier bounce the whole order days later. While the
order is in_review, cancelling is instant — nothing has left the platform.
forwarded_at on the order tells you when it actually went to the carrier.
How long the review takes depends on your account's standing. New accounts
start in manual review: our porting desk verifies every submission
(typically during your first month) before forwarding it. Established
accounts are switched to auto-forward — a successful submit goes to the
losing carrier immediately and you'll see the order move in_review →
submitted in the same instant. The flow, statuses and webhooks are
identical either way; only the review dwell time changes.
Every transition emits a porting_order.status_changed webhook.
The flow: open an order, fill it in, submit
You manage the order like a shopping cart: open it, add data and documents in any order over any time span, and submit when everything is in place. You never write, render, print or upload an LOA — show the end user the LOA text we give you, collect their drawn signature as a PNG, and send that:
1. POST /v2/portability_checks can this number port?
2. POST /v2/porting_orders open draft order(s), requirements attached
3. PATCH /v2/porting_orders/{id}/requirements/{slug} fulfill each requirement (e.g. …/recent_invoice)
4. GET /v2/porting_orders/{id}/readiness anything still missing?
5. POST /v2/porting_orders/{id}/submit hand it to our review desk (in_review)
Step 1 — check the number. Send the raw numbers; get back whether each is portable, its country, number type, and current carrier:
curl -X POST https://api.example.com/v2/portability_checks \
-H "Authorization: Bearer pk_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"phone_numbers": ["+447912345678"]}'
Step 2 — open the order. The phone numbers are all it takes — a
JSON POST /v2/porting_orders creates draft orders with the route's
requirements already attached (snapshotted from the per-country catalog).
Supports the Idempotency-Key header:
curl -X POST https://api.example.com/v2/porting_orders \
-H "Authorization: Bearer pk_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"phone_numbers": ["+447912345678"], "customer_reference": "crm-1042"}'
The response is an array of orders (see splitting below), each carrying
its requirements with per-field status — that array is the complete
checklist: account holder name, service address, account number, documents,
the LOA. You can also preview the requirements before creating anything —
just ask for the number itself:
GET /v2/porting/requirements?phone_number=%2B447912345678
(or for a route: ?country_code=GB&number_type=mobile).
Step 3 — fulfill the requirements, one PATCH each, in any order (LOA
last) — see the Requirements section below for the shapes
(text value, address object, document id, LOA signature). The order stays a
draft between calls; order-level settings (FOC date, reference, webhook
override) can be edited anytime with PATCH /v2/porting_orders/{id}.
Step 4 — check readiness. A dry run of the submit validation, so your UI can show exactly what is still missing without triggering a failed submit:
curl "https://api.example.com/v2/porting_orders/{id}/readiness" \
-H "Authorization: Bearer pk_live_YOUR_KEY"
{
"order_id": "8b2f1747-…",
"status": "draft",
"ready": false,
"errors": [],
"requirements": [
{"slug": "loa", "fulfilled": true, "problem": null, "...": "..."},
{"slug": "recent_invoice", "fulfilled": false, "problem": "not fulfilled", "...": "..."}
]
}
ready: true means POST …/submit will pass local validation right now.
Step 5 — submit. Validated locally first (same checks as readiness);
a 422 lists exactly what is missing and nothing is sent:
curl -X POST "https://api.example.com/v2/porting_orders/{id}/submit" \
-H "Authorization: Bearer pk_live_YOUR_KEY"
From here everything is asynchronous: watch porting_order.status_changed
webhooks (or poll GET /v2/porting_orders/{id}) as the order moves through
in_process → foc_scheduled → activating → completed, at which point the
number is live in your registry (GET /v2/numbers).
Deleting a draft
Changed your mind before submitting? Delete the draft — it disappears along with its registry reservations, so the numbers can be ordered again immediately. Uploaded documents stay in your library (they are reusable):
curl -X DELETE "https://api.example.com/v2/porting_orders/{id}" \
-H "Authorization: Bearer pk_live_YOUR_KEY"
Only draft orders can be deleted (409 otherwise). Anything already
submitted must go through cancellation instead — the
provider side has to be unwound.
Order splitting
One POST /v2/porting_orders may return several orders. Numbers are
grouped by country and number type. All orders from one request share a
batch_id, and a porting_order.split event tells you it happened. Each
order is fulfilled and submitted independently — always iterate over the
response array.
Providers may split further (e.g. by losing carrier). That happens when our
review desk forwards your order to the carrier — not when you create it —
so the sibling orders appear in the batch already submitted, with every
requirement, document and approval inherited from the order you fulfilled.
A second porting_order.split event (same batch_id, listing the new
ids) is sent when it happens; after that, each sibling reports its own
status changes. Filter GET /v2/porting_orders?batch_id=… to see all of
them.
Requirements
Each order carries requirements — one array with everything you need to
provide: the universal end-user fields (auth_person_name, entity_name,
billing_phone_number, location) plus the documents and data legally
required for its country, number type, and provider. Check them before
ordering — pass the number itself (we classify it and answer for its route;
phone_number in the response echoes the normalized number), or ask for a
route by country_code + number_type:
curl "https://api.example.com/v2/porting/requirements?phone_number=%2B447912345678" \
-H "Authorization: Bearer pk_live_YOUR_KEY"
# or by route:
curl "https://api.example.com/v2/porting/requirements?country_code=GB&number_type=mobile" \
-H "Authorization: Bearer pk_live_YOUR_KEY"
Requirement statuses: requirement-info-pending → you must act;
requirement-info-under-review → we are validating;
requirement-info-exception → refused, see exception_reason, fix and resubmit;
approved → done.
Requirements are addressed by their slug — recent_invoice on a GB
order always means the same thing, so there is no per-requirement id to
keep track of. How you fulfill one depends on its field_type:
- textual — PATCH the value;
- address — PATCH an address object (
street_addressandcountry_coderequired); - document — PATCH the file itself (multipart), or PATCH the id of
a document you already uploaded via
POST /v2/documents; - signature (the LOA) — PATCH the end user's drawn signature as a base64 PNG. The platform does the rest.
# textual
curl -X PATCH https://api.example.com/v2/porting_orders/{order_id}/requirements/account_number \
-H "Authorization: Bearer pk_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"field_value": "AB1234567"}'
# address (the service address)
curl -X PATCH https://api.example.com/v2/porting_orders/{order_id}/requirements/location \
-H "Authorization: Bearer pk_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"field_value": {"street_address": "1 High Street", "locality": "London",
"postal_code": "SW1A 1AA", "country_code": "GB"}}'
# document — one call: upload the file straight to the requirement
curl -X PATCH https://api.example.com/v2/porting_orders/{order_id}/requirements/recent_invoice \
-H "Authorization: Bearer pk_live_YOUR_KEY" \
-F "file=@invoice.pdf" -F "document_date=2026-08-01T00:00:00Z"
# signature (LOA)
curl -X PATCH https://api.example.com/v2/porting_orders/{order_id}/requirements/loa \
-H "Authorization: Bearer pk_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"signature": "data:image/png;base64,iVBORw0KGgo...",
"locale": "es", "accepted_disclosures": ["authorised", "cease_service"]}'
The direct upload stores the file in your document library like any other
upload (it gets an id, appears in GET /v2/documents, is downloadable).
Prefer the two-step flow — POST /v2/documents first, then PATCH the
returned id — when one file serves several orders (e.g. after a split):
upload once, attach everywhere.
Invoices and LOAs must usually be fresher than 30 days — we validate this at submit time so you fail fast instead of waiting days for a carrier rejection.
The LOA (Letter of Authorization)
The platform hosts the LOA end-to-end — you never write LOA wording, never render a PDF, and never handle print/scan/upload. Your only job: show the text, collect a drawn signature.
- Get the text. The localized LOA wording ships in two places, so both flows have it when they need it:
- route preview:
GET /v2/porting/requirements?country_code=GB→loa(placeholders like{{account_holder}}unresolved — no order yet); - on the order: every
field_type: "signature"requirement onGET /v2/porting_orders/{id}carries aloablock with placeholders already filled from the order's other requirements. Anything still shown as{{…}}tells you which requirement is still unfilled.
Each block has text keyed by language (en, es, fr, de, ar on
the built-in templates), the disclosures to show as checkboxes, and a
signature_spec describing the PNG constraints (min 200×60 px, ≤ 512 KB).
-
Collect the signature — render the text in the end user's language, let them draw a signature (canvas → PNG), and PATCH it onto the requirement:
bash curl -X PATCH ".../v2/porting_orders/{order_id}/requirements/loa" \ -H "Authorization: Bearer pk_live_YOUR_KEY" -H "Content-Type: application/json" \ -d '{"signature": "data:image/png;base64,…", "locale": "es", "signer_name": "Jane Doe", "signer_email": "jane@acme.example"}'
signer_name defaults to the order's auth_person_name requirement and
locale (the language you displayed) defaults to en.
The platform validates the PNG, renders the signed LOA PDF from the
order's other requirements, appends a Certificate of Completion page (signer
name, timestamp, IP, user agent, template version, language shown,
SHA-256), stores it as a Document (its id lands in the requirement's
field_value), and emits a loa.signed event.
-
Audit lookup whenever needed (regulatory disputes, ops investigations) — and the signed PDF is downloadable like any document:
bash curl https://api.example.com/v2/porting/loa/signatures/{document_id} \ -H "Authorization: Bearer pk_live_YOUR_KEY" curl https://api.example.com/v2/documents/{document_id}/download \ -H "Authorization: Bearer pk_live_YOUR_KEY" -o signed_loa.pdf
Two things to know:
- One signature is enough. Editing a field after the LOA is signed does
not invalidate the signature or block submission — fix the data and
resubmit. Our review desk checks every order before it reaches the
carrier; only if it judges a change really requires a fresh signature
will it flag the
loarequirement as an exception (with the reason). Signing last is still the tidiest habit: the PDF then shows the final values. - Disclosures default to accepted. Omitting
accepted_disclosuresmeans the signature covers all required disclosures (they are printed above the signature line on the PDF, like a paper LOA). Pass the list explicitly if your UI tracks each checkbox.
End-user data
End-user data must match the losing carrier's records exactly —
mismatches are the second most common rejection cause. It is ordinary
requirements, on every order: auth_person_name (mandatory), location
(mandatory, an address object), entity_name and billing_phone_number
(optional), plus route-specific ones like account_number or
port_out_pin. Fill each like any other requirement —
PATCH …/requirements/auth_person_name, PATCH …/requirements/location.
There is no separate "end user object" to manage: the requirements array is
the single place these facts live, and the platform assembles what the
carrier and the LOA need from it.
Rejections
If the order hits rejection, the problem is fixable — nothing is lost:
GET /v2/porting_orders/{id}— look for requirements in statusrequirement-info-exception; each carries anexception_reasonexplaining exactly what was refused. Notes from our operations team and the provider appear on the order in the dashboard.- Fix the data or documents (only the flagged ones — everything else stays
attached), then
POST …/submitagain. The signed LOA stays valid across these fixes — you never have to re-sign just because a field changed (our desk will flag theloarequirement explicitly in the rare case a fresh signature is genuinely required).
If the carrier refuses the port definitively, the order goes straight to
cancelled with closed_by: "provider" — that one is terminal; create a new
order if you want to retry from scratch.
Cancellation
POST /v2/porting_orders/{id}/cancel. After submission the order enters
cancel_pending — cancellation is not guaranteed once the losing
carrier has confirmed the port; watch for the final cancelled (or a
return to in_process) via webhook. A never-submitted draft doesn't need
cancelling — delete it instead.
Duplicates and idempotency
- A number that is already active or in an active port on the platform is
refused with
409and the existing order ids. - Send
Idempotency-Keyon creation: retries with the same key and body return the original response instead of creating duplicate orders.
