KeepMyNumber
Home Sign in

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 secret now — it is shown once. You will use it to verify the Porting-Signature header 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 at GET /v2/webhook_deliveries. Polling GET /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.