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_eventsempty 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 thewebhook_urlfield 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:
GET …/readinessuntilready: true.POST …/submit— the order parks inin_reviewagain and moves on tosubmittedonce 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 |
