Skip to main content

Bulk order recovery: the troubleshooting runbook

A bulk DID purchase fails at the line level, not the order level — POST /numbers/buy-bulk returns 200 even when every row fails, because the status code reports that the batch was processed, not that it delivered. This runbook is the recovery half of the ledger: Bulk order history tells you where the order record lives and what org-scoping it carries; this page tells you what to DO with a partial or failed status once you find one. The loop this page teaches: read the order, decode each failed line’s error code, apply the play for that code, and re-issue only the failed lines as a fresh batch. Failed lines are never charged, so the loop is always safe to run.

1. The failure model: per-line errors, aggregate status

Every bulk order resolves to one aggregate status plus a per-line breakdown:
  • delivered — every requested line succeeded. Nothing to recover.
  • partial — some lines delivered, some failed. Recover only the failed lines; the delivered ones are already active and paid for (debited_cents covers exactly the success subset).
  • failed — no line delivered; nothing was charged (debited_cents: 0). The whole batch is recoverable, or one shared cause (balance, compliance profile) broke every line at once.
The decision that separates a retry from a re-plan:
  • Retry when the cause is transient or inventory-side — NUMBER_NO_LONGER_AVAILABLE, NUMBER_ALREADY_TAKEN. Replace the failed E.164s with fresh inventory and resubmit. Same request shape, new numbers.
  • Re-search when the cause is a request constraint — CHANNEL_UNAVAILABLE (a capability the carrier row never had) or a region with no coverage. Change what you ask for — narrow capabilities, drop the area code, switch country — before resubmitting. Retrying the same constraint against the same inventory burns your 2-requests-per-minute create budget on a guaranteed failure.
  • Fix the gate first when the cause is account-side — INSUFFICIENT_BALANCE or a missing/unapproved compliance profile. No amount of inventory reshuffling helps until the wallet is funded or the profile is approved.

2. Reading the two order APIs

Both reads need an API key with the numbers:read scope; neither mutates anything. GET /numbers/orders — the list endpoint. Returns recent orders as summaries, newest first: order_id, aggregate status, requested_count / delivered_count / failed_count, total_cost_cents, debited_cents, created_at. Use it to find orders you did not capture at create time, or to sweep for any partial/failed status in a nightly reconciliation job. GET /numbers/orders/:id — the detail endpoint. Returns one order with the full per-line lines array: each line carries phone_number, line_status (delivered or failed), and the success fields (country_code, capabilities, monthly_cost_cents) or the failure error object (code, message). This is the decode surface — the error.code on each failed line is what routes you to a play in §4. Orders are org-scoped: an unknown or not-owned id returns 404 — a missing id never leaks that the order exists in another org.

3. Error-code decoder

Per-line failure codes fall into four classes. Match the code to its class, then play the class: A partial order can mix classes — decode each failed line independently. When an unfamiliar code arrives, class it with the same rule of thumb: inventory-side → re-search; constraint-side → narrow the request; account-side → fix the gate; region-side → exclude or switch.

4. Remediation plays per class

Play 1 — Inventory turnover (NUMBER_NO_LONGER_AVAILABLE)

Inventory ages in minutes on hot area codes; the number you searched may belong to another buyer by checkout. The fix is a fresh search, not a blind retry:
  1. Re-run GET /numbers/available with the same filters — the search criteria in Buy and provision numbers §4.
  2. Replace each failed E.164 with a fresh row from the new results.
  3. Resubmit a batch containing only the replacements. Resubmitting a delivered line fails that line fast with NUMBER_ALREADY_TAKEN (the per-number mutex) and is never double-billed — but it wastes one of your 2-creates-per-minute.

Play 2 — Capability mismatch (CHANNEL_UNAVAILABLE)

The requested capability set didn’t intersect the carrier’s advertised set for that row. Two ways out:
  • Narrow the request: pass exactly the capability subset the inventory row advertised (GET /numbers/available returns each row’s capabilities). An explicit request on a voice-only DID that asks for sms writes the mismatch; pass ["voice"] instead — see the capabilities field in Buy and provision numbers §4.
  • Switch number type: a toll-free number’s SMS is send-only. If you need two-way SMS, buy a local or mobile DID instead of forcing the capability onto a type that can’t carry it.

Play 3 — Compliance gate (COMPLIANCE_PROFILE_REQUIRED, COMPLIANCE_PROFILE_NOT_APPROVED)

A registration-required country rejected the batch because no approved profile was attached. Approval happens outside the order flow:
  1. Complete the profile per Regulatory Preview.
  2. Wait for approval — a COMPLIANCE_PROFILE_NOT_APPROVED line retries into the same failure until the profile flips.
  3. Resubmit the batch with compliance_profile_id at batch level (one profile per order).
Porting a batch instead of buying one? The same gate exists there — the batch port-in walkthrough runs the full preflight → LOA → dispatch sequence for ported inventory.

Play 4 — Region/carrier coverage (carrier-hold codes)

When the error.message names a region or carrier hold rather than inventory turnover:
  • Exclude the region: drop the area code or locality from your search filters and re-search. A US 50-line batch blocked on one metro splits cleanly.
  • Switch country: if your use case tolerates it, an adjacent country with the same capability set often clears a coverage hold.
  • Move to the bulk-reserve path: for blocks over 50, or when you want the platform to hold matching DIDs while your batch-level decisions settle, use Bulk Reserve — hold up to 1000 DIDs matching a country/capability filter for 15 minutes, then finalize or cancel. The reserve-then-finalize shape sidesteps the search-to-checkout race that causes NUMBER_NO_LONGER_AVAILABLE on the direct buy path.

5. Polling cadence and the rate-limit asymmetry

The rate-limit asymmetry is the whole polling design: POST /numbers/buy-bulk is limited to 2 requests per minute per tenant (one call fans out to up to 50 carrier calls), but the order-read endpoints carry no such ceiling — poll GET /numbers/orders/:id on whatever cadence your reconciliation needs; the create-path limit never gates reading. Practical cadence for a remediation loop:
  1. Issue the batch (create-path budget: 2/min).
  2. Poll the detail endpoint every few seconds until status is no longer moving — bulk purchases resolve in one request, so one read is usually enough.
  3. Decode the failed lines, run the play, resubmit (second create in the same minute-window).
  4. A nightly sweep over GET /numbers/orders (list) catches anything nobody polled live.
The trap to avoid: a poll-and-retry loop that fires a fresh buy-bulk per poll interval. The create budget is the scarce resource; reads are free.

6. Worked remediation loop

The scenario: a two-line US batch comes back partial. Line 1 delivered; line 2 failed with NUMBER_NO_LONGER_AVAILABLE. Step 1 — read the order. GET /numbers/orders/numOrder_01J9Z8ABCDEF returns:
Step 2 — decode. NUMBER_NO_LONGER_AVAILABLE is inventory turnover → Play 1. debited_cents: 150 of the total_cost_cents: 300 confirms only the delivered line was charged. Step 3 — re-search. GET /numbers/available with the same filters returns a fresh candidate, say +14155550220. Step 4 — re-issue just the failed line. Submit a one-line batch containing only the replacement:
The resubmitted batch carries no bundle-level idempotency key — replace failed lines only, and let the per-number mutex guard anything you re-send. The original order record is immutable; the new batch creates its own order_id, and the pair reconciles against usage exports via CDR export & billing reconciliation.

7. Recovery in the dashboard

The same ledger renders under Numbers → Buy → Orders. Open an order row to expand the per-line breakdown; each failed line shows its carrier error inline, so the decode step happens in the console without an API read. Re-search and re-issue from Numbers → Buy — the console’s inventory search is the same GET /numbers/available behind the API play.

See Also

  • Bulk order history — the ledger surface this runbook decodes: both read endpoints, org-scoping, and the reconcile-against-usage pattern.
  • Buy and provision numbers — the search → buy → wire loop, with the buy-bulk request shape in §4.
  • Bulk Reserve — hold up to 1000 DIDs, then finalize or cancel — the path that skips the search-to-checkout race.
  • Batch port-in walkthrough — the staged-shipment recovery discipline when the numbers come from porting, not purchase.
  • Inbound health — after recovered lines land, repair any missing routing before traffic starts.