Porting walkthrough (full example)
A complete, real port from start to finish — every request you send and every
response you get back, porting the UK mobile number +447912345678. All
responses below are actual API output; only the API key is a placeholder.
You open an order, fill it in at your own pace, then submit (plus a one-time webhook registration):
0. POST /v2/webhook_endpoints (once) where we push progress updates
1. POST /v2/portability_checks can this number port?
2. GET /v2/porting/requirements what does this route need? + the LOA text to show
3. POST /v2/porting_orders open the draft order
4. PATCH /v2/porting_orders/{id}/requirements/{slug} fulfill each requirement
5. GET /v2/porting_orders/{id}/readiness anything missing?
6. POST /v2/porting_orders/{id}/submit hand it to our review desk (in_review)
Step 0 — register your webhook URL (once)
curl -X POST https://port.keepmynum.com/v2/webhook_endpoints \
-H "Authorization: Bearer pk_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"url": "https://acme.example.com/hooks/porting"}'
{
"id": "3b7a134b-6f49-46e0-8aba-104e321bb7b7",
"url": "https://acme.example.com/hooks/porting",
"description": "",
"enabled_events": [],
"active": true,
"created_at": "2026-08-12T08:36:49Z",
"secret": "whsec_3e4417456f3917d12f57f6b7bbfdef85dc0aba6e1b4b9455"
}
Store the
secretnow — it is shown once. You will use it to verify thePorting-Signatureheader on every webhook we send (see the Webhooks guide).
Step 1 — can the number port?
curl -X POST https://port.keepmynum.com/v2/portability_checks \
-H "Authorization: Bearer pk_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"phone_numbers": ["+447912345678"]}'
[
{
"phone_number": "+447912345678",
"portable": true,
"not_portable_reason": null,
"fast_portable": false,
"country_code": "GB",
"number_type": "mobile",
"carrier": { "name": "Mock UK Carrier Ltd" },
"provider": "telnyx"
}
]
The check tells you the country (GB), the number type (mobile), and the
current carrier. You need country_code and number_type for the next call.
Step 2 — what does this route need?
curl "https://port.keepmynum.com/v2/porting/requirements?country_code=GB&number_type=mobile" \
-H "Authorization: Bearer pk_live_YOUR_KEY"
{
"country_code": "GB",
"number_type": "mobile",
"provider": "telnyx",
"requirements": [
{
"slug": "auth_person_name",
"name": "Authorized person (full name)",
"field_type": "textual",
"description": "The person authorizing the port — the account holder or someone with written authority. Also the default LOA signer name.",
"example": "Jane Doe",
"acceptance_criteria": {}
},
{
"slug": "entity_name",
"name": "Business name",
"field_type": "textual",
"description": "The company that holds the account at the losing carrier (business ports only — leave empty for personal numbers).",
"example": "Acme Ltd",
"acceptance_criteria": {}
},
{
"slug": "billing_phone_number",
"name": "Billing telephone number",
"field_type": "textual",
"description": "The main number on the losing carrier's bill.",
"example": "+447912345678",
"acceptance_criteria": {}
},
{
"slug": "location",
"name": "Service address",
"field_type": "address",
"description": "The end user's service address at the losing carrier. The value is an address object; street_address and country_code are required.",
"example": "{\"street_address\": \"1 High Street\", \"locality\": \"London\", \"postal_code\": \"SW1A 1AA\", \"country_code\": \"GB\"}",
"acceptance_criteria": {}
},
{
"slug": "loa",
"name": "Letter of Authorization (e-signature)",
"field_type": "signature",
"description": "The platform generates and stores the LOA. Show the end user the localized LOA text carried in this requirement's `loa` block, then submit their drawn signature as a base64 PNG — the platform renders the signed PDF, keeps the audit trail, and attaches it automatically.",
"example": "data:image/png;base64,iVBORw0KGgo…",
"acceptance_criteria": { "max_age_days": 30 }
},
{
"slug": "account_number",
"name": "Losing carrier account number",
"field_type": "textual",
"description": "The account number at the current (losing) carrier.",
"example": "123456789",
"acceptance_criteria": {}
},
{
"slug": "recent_invoice",
"name": "Recent invoice from the losing carrier",
"field_type": "document",
"description": "A copy of the latest phone bill/invoice from the current provider.",
"example": "Most recent phone bill (PDF)",
"acceptance_criteria": { "max_age_days": 30 }
}
],
"mandatory_flags": [true, false, false, true, true, true, true],
"loa": {
"template_id": "6f2a91d0-8a11-4a70-9df1-3fbb2f6a7c1e",
"template_version": 2,
"languages": ["ar", "de", "en", "es", "fr"],
"text": {
"en": {
"title": "UK Letter of Authorization for Number Porting",
"body": "I, {{signer_name}}, authorize KeepMyNumber Ltd … The account holder of record is {{account_holder}}, with service address {{service_address}}. Donor provider account number: {{losing_account_number}}. …",
"disclosures": [
{"slug": "authorised", "label": "I confirm that I am the account holder or that I hold written authority to sign on their behalf.", "required": true},
{"slug": "cease_service", "label": "I understand that porting will end voice service with the donor provider for the listed numbers.", "required": true}
]
},
"es": {
"title": "Carta de Autorización para la Portabilidad Numérica (Reino Unido)",
"body": "Yo, {{signer_name}}, autorizo a KeepMyNumber Ltd …",
"disclosures": [ "…" ]
}
},
"signature_spec": {
"mime_type": "image/png",
"min_width_px": 200, "min_height_px": 60, "max_bytes": 524288
}
}
}
One array, everything you need to collect. It always starts with the
universal end-user fields — auth_person_name, entity_name,
billing_phone_number and location (every country needs them) — followed
by the route-specific entries: here the LOA signature, the losing
carrier account number, and one document (recent_invoice, no
older than 30 days). field_type tells you how to fulfill each: textual
(a string), address (an address object), document (a file), or
signature (a drawn-signature PNG). mandatory_flags lines up with the
array — entity_name and billing_phone_number are optional.
Because the route has a signature requirement, the response also carries
loa — the full LOA wording in five languages. Render loa.text[locale]
plus the disclosure checkboxes in your UI and let the end user draw their
signature (a canvas exported as PNG). The {{…}} placeholders are filled
by the platform from the order's other requirements; you never substitute
them yourself.
Step 3 — open the draft order
The phone numbers are all it takes:
curl -X POST https://port.keepmynum.com/v2/porting_orders \
-H "Authorization: Bearer pk_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"phone_numbers": ["+447912345678"], "customer_reference": "crm-1042"}'
Response — 201, an array with one draft order. Its requirements
mirror the preview from step 2, now with per-requirement statuses — all
still open. You fill them next, addressed by the same slug you saw in the
preview. (Deleting the draft is always an option:
DELETE /v2/porting_orders/{id}.)
Step 4 — fulfill the requirements
One PATCH per requirement, in any order — except the LOA, which you sign last (the platform renders it from the other fields). Text fields take the value directly, the service address takes an address object:
curl -X PATCH https://port.keepmynum.com/v2/porting_orders/8b2f1747-…/requirements/auth_person_name \
-H "Authorization: Bearer pk_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"field_value": "Jane Doe"}'
curl -X PATCH https://port.keepmynum.com/v2/porting_orders/8b2f1747-…/requirements/account_number \
-H "Authorization: Bearer pk_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"field_value": "AB1234567"}'
curl -X PATCH https://port.keepmynum.com/v2/porting_orders/8b2f1747-…/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"}}'
(entity_name and billing_phone_number are optional — fill them the same
way if they apply.) The invoice is a document requirement — upload the file
straight to it, one call:
curl -X PATCH https://port.keepmynum.com/v2/porting_orders/8b2f1747-…/requirements/recent_invoice \
-H "Authorization: Bearer pk_live_YOUR_KEY" \
-F "file=@invoice.pdf" -F "document_date=2026-08-01T00:00:00Z"
The file lands in your document library (its id comes back in the
requirement's field_value). If you'd rather upload once and attach to
several orders, use POST /v2/documents first and PATCH the returned id as
{"field_value": "<document_id>"} instead.
Finally the LOA: show the end user loa.text[locale] from the requirement
(the placeholders now resolve to the values you just filled), collect their
drawn signature, and PATCH it. The platform validates the PNG, renders the
signed LOA PDF from the order's fields, appends a Certificate of
Completion, stores it, attaches it, and emits loa.signed:
curl -X PATCH https://port.keepmynum.com/v2/porting_orders/8b2f1747-…/requirements/loa \
-H "Authorization: Bearer pk_live_YOUR_KEY" -H "Content-Type: application/json" \
-d '{"signature": "data:image/png;base64,iVBORw0KGgo…",
"signer_name": "Jane Doe", "signer_email": "jane@acme.example",
"locale": "en"}'
(signer_name defaults to the auth_person_name requirement, locale — the
language you displayed — defaults to en; omitting accepted_disclosures
means the signature covers all required disclosures, exactly like a paper
LOA where they are printed above the signature line.)
Step 5 — ready?
A dry run of the submit validation — nothing is sent anywhere:
curl "https://port.keepmynum.com/v2/porting_orders/8b2f1747-…/readiness" \
-H "Authorization: Bearer pk_live_YOUR_KEY"
{
"order_id": "8b2f1747-51b6-4f5e-baae-a44d25eaada9",
"status": "draft",
"ready": true,
"errors": [],
"requirements": [
{"slug": "auth_person_name", "fulfilled": true, "problem": null, "…": "…"},
{"slug": "entity_name", "fulfilled": false, "problem": null, "…": "…"},
{"slug": "billing_phone_number", "fulfilled": false, "problem": null, "…": "…"},
{"slug": "location", "fulfilled": true, "problem": null, "…": "…"},
{"slug": "loa", "fulfilled": true, "problem": null, "…": "…"},
{"slug": "account_number", "fulfilled": true, "problem": null, "…": "…"},
{"slug": "recent_invoice", "fulfilled": true, "problem": null, "…": "…"}
]
}
Had you skipped the invoice, ready would be false and its entry would
say "problem": "not fulfilled" — the same information a failed submit
returns, but without the failed submit.
Step 6 — submit
curl -X POST https://port.keepmynum.com/v2/porting_orders/8b2f1747-…/submit \
-H "Authorization: Bearer pk_live_YOUR_KEY"
Response — 200, the order is in review: it is now with our porting
desk, which verifies every field and document before anything is sent to the
losing carrier. You'll receive a porting_order.status_changed webhook when
it moves on to submitted (review passed, sent to the carrier —
forwarded_at records the moment) or to rejection with the flagged field
if something needs fixing.
{
"id": "8b2f1747-51b6-4f5e-baae-a44d25eaada9",
"batch_id": "bf107556-1f21-4621-9e87-b0a55e7ed25d",
"parent_order_id": null,
"country_code": "GB",
"number_type": "mobile",
"status": "in_review",
"provider_status": null,
"sub_status": null,
"requirements_status": false,
"foc_requested": null,
"foc_actual": null,
"webhook_url": null,
"customer_reference": "crm-1042",
"routing_config": {},
"submitted_at": "2026-08-12T08:36:49Z",
"forwarded_at": null,
"completed_at": null,
"created_at": "2026-08-12T08:36:49Z",
"updated_at": "2026-08-12T08:36:49Z",
"numbers": [
{
"phone_number": "+447912345678",
"portability_status": "portable",
"not_portable_reason": null,
"activation_status": "pending",
"carrier_name": "Mock UK Carrier Ltd"
}
],
"requirements": [
{
"slug": "auth_person_name",
"name": "Authorized person (full name)",
"field_type": "textual",
"acceptance_criteria": {},
"status": "requirement-info-under-review",
"mandatory": true,
"field_value": "Jane Doe",
"exception_reason": null
},
{
"slug": "location",
"name": "Service address",
"field_type": "address",
"acceptance_criteria": {},
"status": "requirement-info-under-review",
"mandatory": true,
"field_value": {
"street_address": "1 High Street",
"locality": "London",
"postal_code": "SW1A 1AA",
"country_code": "GB"
},
"exception_reason": null
},
{
"slug": "loa",
"name": "Letter of Authorization (e-signature)",
"field_type": "signature",
"acceptance_criteria": { "max_age_days": 30 },
"status": "requirement-info-under-review",
"mandatory": true,
"field_value": "ac88f3af-f201-475d-86ba-f647ea4e6627",
"exception_reason": null,
"loa": { "languages": ["ar", "de", "en", "es", "fr"], "text": { "…": "…" } }
},
{
"slug": "account_number",
"name": "Losing carrier account number",
"field_type": "textual",
"acceptance_criteria": {},
"status": "requirement-info-under-review",
"mandatory": true,
"field_value": "AB1234567",
"exception_reason": null
},
{
"slug": "recent_invoice",
"name": "Recent invoice from the losing carrier",
"field_type": "document",
"acceptance_criteria": { "max_age_days": 30 },
"status": "requirement-info-under-review",
"mandatory": true,
"field_value": "4273f757-98f2-4bd9-832f-c5c6a392d9e9",
"exception_reason": null
}
]
}
(The optional entity_name and billing_phone_number entries are elided
here for brevity — they look just like auth_person_name.) Note the
location requirement returns its field_value as an address object.
The loa requirement's field_value holds the id of the
platform-generated signed LOA PDF (download it anytime via
GET /v2/documents/{id}/download; audit record at
GET /v2/porting/loa/signatures/{id}), and the uploaded invoice became a
document attached to its requirement. Track the order anytime with
GET /v2/porting_orders/8b2f1747-51b6-4f5e-baae-a44d25eaada9 — same shape.
Had a mandatory requirement been missing, the submit would have returned a
422 naming exactly what to fix (the same list the readiness endpoint
gives you), and the order would have stayed an editable draft:
{
"detail": {
"message": "Order is not ready",
"errors": ["requirement 'recent_invoice' is not fulfilled"]
}
}
Step 7 — progress arrives on your webhook
From here the port is asynchronous. First our porting desk reviews the
submission (new accounts are on manual review, typically for their first
month; established accounts are switched to auto-forward and skip the
wait — the submit response then already shows submitted): each field it
accepts flips to approved (you see this on the order and as
porting_order.requirement_updated events); when everything
passes, the order moves to submitted — it is now with the losing carrier,
which drives the timeline from there. Each transition lands on your webhook
URL as a signed POST. This is what your server receives when the port moves
to in_process:
POST /hooks/porting HTTP/1.1
Content-Type: application/json
Porting-Event-Id: e9244ef6-9220-4783-81f1-f218b7055fe7
Porting-Event-Type: porting_order.status_changed
Porting-Signature: t=1786610209,v1=4f6c2f... (HMAC-SHA256, verify with your whsec_ secret)
{
"id": "e9244ef6-9220-4783-81f1-f218b7055fe7",
"type": "porting_order.status_changed",
"created_at": "2026-08-12T08:36:49Z",
"payload": {
"porting_order": {
"id": "8b2f1747-51b6-4f5e-baae-a44d25eaada9",
"batch_id": "bf107556-1f21-4621-9e87-b0a55e7ed25d",
"status": "in_process",
"provider_status": "in_process",
"country_code": "GB",
"number_type": "mobile",
"customer_reference": "crm-1042",
"requirements_status": true,
"foc_requested": null,
"foc_actual": null,
"phone_numbers": ["+447912345678"]
},
"previous_status": "submitted",
"new_status": "in_process"
}
}
When the port date is confirmed you get foc_scheduled with the date in
foc_actual:
{
"type": "porting_order.status_changed",
"payload": {
"porting_order": {
"id": "8b2f1747-51b6-4f5e-baae-a44d25eaada9",
"status": "foc_scheduled",
"foc_actual": "2026-08-20T09:00:00+00:00",
"phone_numbers": ["+447912345678"]
},
"previous_status": "in_process",
"new_status": "foc_scheduled"
}
}
On the port date: activating, then two final events — the number going live
and the order completing:
{
"type": "number.activated",
"payload": {
"number": {
"id": "66a7fc25-576f-4787-8ff1-92f79c14faa8",
"phone_number": "+447912345678",
"status": "active_unconfigured"
},
"porting_order_id": "8b2f1747-51b6-4f5e-baae-a44d25eaada9"
}
}
{
"type": "porting_order.status_changed",
"payload": {
"porting_order": { "id": "8b2f1747-51b6-4f5e-baae-a44d25eaada9", "status": "completed" },
"previous_status": "activating",
"new_status": "completed"
}
}
Missed a webhook? Nothing is lost: the full history is queryable at
GET /v2/porting/events?porting_order_id=…, and every delivery attempt is auditable atGET /v2/webhook_deliveries. PollingGET /v2/porting_orders/{id}always returns the current truth.
If our review desk or the losing carrier refuses something fixable you get
rejection instead — read GET /v2/porting_orders/{id} (look for
requirements in requirement-info-exception and their exception_reason),
fix the data, and POST …/submit again (the order parks for re-review). A
definitive refusal goes to cancelled with closed_by: "provider".
Step 8 — the number is yours
curl "https://port.keepmynum.com/v2/numbers?phone_number=%2B447912345678" \
-H "Authorization: Bearer pk_live_YOUR_KEY"
[
{
"id": "66a7fc25-576f-4787-8ff1-92f79c14faa8",
"phone_number": "+447912345678",
"country_code": "GB",
"number_type": "mobile",
"status": "active_unconfigured",
"ported_in_at": "2026-08-12T08:36:49Z",
"ported_out_at": null,
"source_order_id": "8b2f1747-51b6-4f5e-baae-a44d25eaada9",
"routing_config": {},
"created_at": "2026-08-12T08:36:49Z"
}
]
active_unconfigured means the number is live but has no routing yet — pass
routing_config when creating the order (or PATCH /v2/numbers/{id} later)
and the status becomes active.
Recap
| Call | Purpose |
|---|---|
POST /v2/portability_checks |
Is it portable? Which country/type/carrier? |
GET /v2/porting/requirements |
Which slugs does the route need + the LOA text to show the signer |
POST /v2/porting_orders |
Open the draft order(s), requirements attached |
PATCH …/requirements/{slug} |
Fulfill each requirement (text / document id / LOA signature PNG) |
GET …/readiness |
Dry run: what is still missing before submit |
POST …/submit |
Submit for review (in_review); we verify every field, then send it to the losing carrier |
webhooks / GET /v2/porting_orders/{id} |
Progress until completed |
GET /v2/numbers |
The ported number in your registry |
Details on each topic: Port-in orders (lifecycle, splitting, rejections), Porting endpoint reference, Webhooks (signature verification, retries), Errors & idempotency.
