KeepMyNumber
Home Sign in

Webhooks

We push an event to your registered URLs whenever anything meaningful happens. Webhooks are the fast path; the events API is the queryable history behind them.

Registering endpoints

curl -X POST https://api.example.com/v2/webhook_endpoints \
  -H "Authorization: Bearer pk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://yourapp.com/hooks/porting",
       "enabled_events": ["porting_order.status_changed", "number.activated"]}'
  • Leave enabled_events empty to receive all event types.
  • The response contains the endpoint's signing secret — shown once.
  • You can register multiple endpoints, pause them (active: false), rotate secrets (POST …/rotate_secret), and set a per-order override with the webhook_url field on a porting order.
  • URLs must be HTTPS.
  • The full list of event types: GET /v2/webhook_deliveries/meta/event_types.

Delivery format

POST https://yourapp.com/hooks/porting
Content-Type: application/json
Porting-Event-Id: 55f2…
Porting-Event-Type: porting_order.status_changed
Porting-Signature: t=1765432100,v1=6c3f0e…

{"id": "55f2…", "type": "porting_order.status_changed",
 "created_at": "2026-08-12T10:00:00Z",
 "payload": {"porting_order": {…}, "previous_status": "in_process",
             "new_status": "foc_scheduled"}}

Respond with any 2xx within 15 seconds. Anything else counts as a failure and triggers retries.

Verifying signatures

The signature is HMAC-SHA256 over "<timestamp>.<raw body>" with your endpoint's secret. Always verify it, and reject stale timestamps (replay protection):

import hashlib, hmac, time

def verify(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    if abs(time.time() - int(parts["t"])) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{parts['t']}.".encode() + body,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Order status changes

porting_order.status_changed fires on every lifecycle move. The payload carries the previous and new status plus a compact snapshot of the order:

{
  "id": "55f2c9a1-8e0d-4b3f-9d61-2f4a8c7b1e90",
  "type": "porting_order.status_changed",
  "created_at": "2026-08-12T10:00:00Z",
  "payload": {
    "porting_order": {
      "id": "8b2f1747-51b6-4f5e-baae-a44d25eaada9",
      "batch_id": "bf107556-1f21-4621-9e87-b0a55e7ed25d",
      "status": "foc_scheduled",
      "closed_by": null,
      "provider_status": "foc-date-confirmed",
      "country_code": "GB",
      "number_type": "mobile",
      "customer_reference": "crm-1042",
      "requirements_status": true,
      "foc_requested": null,
      "foc_actual": "2026-08-20T09:00:00+00:00",
      "phone_numbers": ["+447912345678"]
    },
    "previous_status": "in_process",
    "new_status": "foc_scheduled"
  }
}

Every status you can receive in new_status:

Status Meaning What to do
draft Being filled in; not yet sent to the carrier Fulfill requirements, then submit
in_review Submitted to us; our porting desk is verifying every field before the carrier sees anything Wait — you'll get either submitted or a per-field rejection
submitted Review passed; sent to the losing carrier Wait — nothing to do
in_process The losing carrier is processing the port Wait
rejection Something needs fixing — usually one specific field or document (see the next section) Fix the rejected requirement(s), resubmit
foc_scheduled The port date is confirmed (foc_actual) Prepare routing for that date
activating The number is being moved right now Wait
completed The port finished — the number is live on the platform Done (a number.activated event follows)
completed_partial Some numbers of the order ported, some did not Check the numbers, open a new order for the rest if needed
cancel_pending Cancellation requested, waiting for the carrier to confirm Wait
cancelled Terminal close — closed_by says who ended it: customer, provider (final refusal) or ops Start over with a new order if still needed

status on the order is always one of these canonical values, regardless of which carrier is behind it; the carrier's raw status is preserved untouched in provider_status.

When a specific field is rejected

Our review (or the losing carrier) can reject one specific requirement — say the invoice is too old or the account number doesn't match. You get a porting_order.requirement_updated event naming the exact field by its slug, with the reason:

{
  "id": "71c3aa02-4f7e-4f19-b8a4-9e51c2d20777",
  "type": "porting_order.requirement_updated",
  "created_at": "2026-08-13T09:12:00Z",
  "payload": {
    "porting_order_id": "8b2f1747-51b6-4f5e-baae-a44d25eaada9",
    "requirement": {
      "slug": "recent_invoice",
      "status": "requirement-info-exception",
      "reason": "The invoice is older than 30 days — upload the most recent one."
    }
  }
}

The same event (without reason) tells you a field passed review:

{"requirement": {"slug": "recent_invoice", "status": "approved"}}

A rejected field also moves the whole order to rejection — you'll receive a porting_order.status_changed alongside it.

Field statuses

Every requirement on an order carries its own review status — you see it on GET /v2/porting_orders/{id} in each entry of requirements[]:

status Meaning
requirement-info-pending Value submitted (or still empty), not yet reviewed
requirement-info-under-review Being checked by our team / the carrier
requirement-info-exception Rejected — exception_reason says why; rewrite it
approved Accepted — no action needed

A rejected field looks like this on the order:

{
  "slug": "recent_invoice",
  "name": "Recent invoice from the losing carrier",
  "field_type": "document",
  "status": "requirement-info-exception",
  "exception_reason": "The invoice is older than 30 days — upload the most recent one.",
  "field_value": "4273f757-98f2-4bd9-832f-c5c6a392d9e9",
  "mandatory": true
}

Fixing a rejected field

Rewrite just that one field — the same PATCH you used to fill it:

# a document requirement: upload the corrected file straight to it
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-new.pdf" -F "document_date=2026-08-12T00:00:00Z"

# a text requirement: send the corrected value
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"}'

The PATCH resets the field to requirement-info-pending and clears exception_reason. Then:

  1. GET …/readiness until ready: true.
  2. POST …/submit — the order parks in in_review again and moves on to submitted once our desk confirms the fix.

The LOA that was already signed stays valid — correcting a field never forces a re-sign. If our review desk judges the change really does need a fresh signature (rare), it flags the loa requirement as an exception with a reason, and you handle it like any other rejected field.

Retries

Failed deliveries are retried with backoff: 1m, 5m, 30m, 2h, 6h. After the last failure the delivery is marked failed (the event itself is never lost — you can always republish it).

Design your handler to be idempotent: use Porting-Event-Id to deduplicate, because retries can occasionally deliver the same event twice.

The audit log

Every webhook we ever sent you (or tried to) is recorded and queryable:

# everything, newest first — filter by status, event_type, endpoint, order, date
curl "https://api.example.com/v2/webhook_deliveries?status=failed" \
  -H "Authorization: Bearer pk_live_YOUR_KEY"

# one delivery in full: per-attempt history + the exact payload sent
curl https://api.example.com/v2/webhook_deliveries/{delivery_id} \
  -H "Authorization: Bearer pk_live_YOUR_KEY"

# fixed your endpoint? retry a failed delivery immediately
curl -X POST https://api.example.com/v2/webhook_deliveries/{delivery_id}/retry \
  -H "Authorization: Bearer pk_live_YOUR_KEY"

Each delivery's attempt_log lists every HTTP attempt with its response code, error, and duration — enough to debug any delivery problem without contacting support.

Missed events

If your endpoint was down past the retry window, or you need an event again:

curl -X POST https://api.example.com/v2/porting/events/{event_id}/republish \
  -H "Authorization: Bearer pk_live_YOUR_KEY"

Event types

A real captured example of every event type is in Webhook event examples.

Type Fired when
porting_order.created A draft order was created
porting_order.split One request became several orders
porting_order.deleted A never-submitted draft was deleted
porting_order.status_changed An order moved through its lifecycle
porting_order.requirement_updated A field was approved or rejected in review
porting_order.new_comment Our ops team or the carrier left a note on an order
porting_order.foc_date_changed Reserved — currently never sent. Confirmed port dates arrive as foc_actual on porting_order.status_changed
loa.signed A signature was submitted and the signed LOA PDF was generated
number.activated A number went live on the platform
number.provider_changed Platform ops corrected which carrier hosts one of your numbers (rare; forwarding is re-applied to the right carrier)
call.forwarded A call to one of your numbers was forwarded (forwarded_by: platform or carrier; stage: primary / fallback)
call.completed A forwarded call that was answered has ended (duration_seconds, ended_by)
call.missed A forwarded call was never answered (reason)
number.ported_out A number left the platform (this is the port-out completion signal)
port_out.created Another carrier requested one of your numbers
port_out.status_changed A port-out needs your decision or was approved/rejected/expired