Skip to main content

Errors worth branching on

These five failures cover the import job create + status poll (POST /api/v1/imports, GET /api/v1/imports/), hammered by every onboarding wizard. Each block below is a full { error, meta } envelope as the API returns it, and the matrix at the bottom answers retry vs surface for the same five classes. For the platform-wide decision table these branches plug into, see the error handling guide.

401 — Unauthorized

A 401 on this page is the bearer key failing before the route ran — it never means the resource is wrong. Rotate the key or re-mint the scoped token; retrying the same request changes nothing.

403 — Forbidden

A 403 means the key authenticated but the operation is gated by scope — check the key’s scopes on the developer page; a 403 is never a data-not-found shape.

422 — Schema

A 422 means the payload did not match the request schema — branch on error.details.field and poll GET /api/v1/imports/ for the job state instead of re-posting the same payload instead of blind-retrying the same body.

429 — Rate

The Retry-After header and error.details.retry_after are both set on every 429 — resend the SAME request after the lower of the two.

402 — Feature gate

Keep polling the job status; re-run the connect wizard only when the job reports source_lost.

60-second retry matrix

Worked request and response samples

Copy the request as written, substitute your own ids, and compare the response envelope — a success in the same shape the numbered error sections spell out.

Start a contact import

POST /api/v1/imports
Uploads run as jobs — the returned job id is your handle for progress polling and the final row report. Request

Worked provider-import samples

A migration off Twilio, Telnyx, Klaviyo, or MessageBird is a five-step chain: hand over credentials, preview what the source holds, start the run, watch the job, and cancel or roll back if the preview lied. Every response below keeps the { data, meta } envelope. The samples past the Twilio manual-credentials step are numbered cURL + Node.js pairs; the Node tab uses the orbit.request escape hatch because the SDK has no typed imports namespace yet — vendor it from packages/sdk-node, per the SDK index. For the wizard-side walkthrough of the same chain, see the import wizard guide. Credentials are used to READ the source account only — no outbound voice or SMS is ever routed through the source provider as a result of the import; outbound traffic keeps exiting via Devotel’s wholesale softswitch.

1. Hand over the credentials — get back an opaque envelope

The run and dry-run calls never take raw provider keys. You exchange the provider credential ONCE for an encrypted envelope and pass that envelope forward. Use whichever intake the provider supports:
  • Twilio (manual fallback) — Account SID + Auth Token, when the OAuth flow below is unavailable.
  • Telnyx — a V2 API key (starts with KEY).
  • Klaviyo — a private API key (starts with pk_).
  • MessageBird — an access key (live_ / test_ prefixed or bare).
Each intake returns the same shape — the raw key is never echoed back, only the envelope:
200
Carry data.envelope into every dry-run and run call from here on. A malformed key fails the format check locally with a 422 before any upstream round-trip (see the 422 — Credentials branch below); Twilio additionally returns accountSid alongside the envelope.

2. Preview the import — dry-run before anything is written

POST /api/v1/imports/{source}/dry-run
Read three things before committing: estimatedCount per entity, knownConflicts (rows already on the Orbit side — a phone number already in the pool, a contact already imported), and the etaSeconds total. The valid entity kinds are phone_numbers, messaging_services, templates, contacts, conversations, channels, flows, inboxes, tags, teammates, lists, segments — each source supports a subset, and unsupported kinds are rejected by schema. Twilio conversation history caps at 90 days; past that the provider returns nothing more. Nothing is written at this step.

3. Start the run — get a job id back

POST /api/v1/imports/{source}/run
Send the same envelope and entity selection you previewed, plus an optional per-entity conflict policy (skip, overwrite, or merge).
The jobId is the handle for every later step. Credentials ride the job as an encrypted envelope, are decrypted only inside the worker, and are redacted from the job record once the job reaches a terminal status.

4. Poll the job — point-in-time snapshot of progress

GET /api/v1/imports/{jobId}
status walks pending → running → succeeded | failed | cancelled. Poll on a 2–5 second cadence, or subscribe to GET /api/v1/imports/{jobId}/progress for the same counters as a live Server-Sent Events stream (snapshot first frame, then progress/status events, then a terminal end event). A terminal job reports its per-row failures in progress[].failed and its collisions in progress[].conflicts — resolve those in the wizard’s reconcile step.

5. Prefer the OAuth path for Twilio (no secrets to paste)

The manual-credentials paste is the fallback. When Twilio Connect is available the flow is two browser steps and zero secrets through the clipboard.
Open authorizeUrl, and the operator approves on Twilio’s page. Twilio then redirects the browser to the callback — you do not construct this URL yourself, Twilio does, with the authorization code and the single-use state from step A:
Step B — Twilio redirects the browser here (no client code needed)
302
The raw access token never reaches the browser — the callback exchanges the code server-side, encrypts the credentials, and 302s back into the wizard with the same opaque envelope the manual path returns. A user who declines the consent lands on a 400 TWILIO_OAUTH_DENIED; a state older than 10 minutes or posted twice lands on 400 INVALID_OAUTH_STATE. When Twilio Connect is not configured at all, step A returns 503 TWILIO_CONNECT_NOT_CONFIGURED — fall back to the manual-credentials intake in step 1.

6. Cancel an in-flight job

POST /api/v1/imports/{jobId}/cancel
The worker re-checks status between pages, so a cancel takes effect at the next page boundary rather than mid-row. Only pending or running jobs can be cancelled — cancelling a finished job returns the 409 IMPORT_NOT_CANCELLABLE shape below. Rows already imported stay imported; to undo a completed import, roll back instead.

7. Roll back a completed import — undo what it wrote

POST /api/v1/imports/{jobId}/rollback
Destructive: deletes every contact the batch created, in one transaction, and records the rollback on the job. Owner or admin role required; the job must be in a terminal status.
A job already rolled back returns 409 ALREADY_ROLLED_BACK; one still running returns 409 IMPORT_STILL_RUNNING (cancel it first). A non-owner/admin key gets the 403 shape from the error section above with the rollback-specific message.

8. Retries are safe — pin writes to an Idempotency-Key

Every mutating endpoint on this page accepts an Idempotency-Key header. Pass a stable, client-generated value and the second delivery of the same request replays the first response — the run is not enqueued twice.
Same key + same body returns the recorded response with an Idempotency-Replay: true header and the original request_id — no second job. A retry that arrives while the first delivery is still in flight gets the 409 in-flight conflict below; back off and retry it (or simply re-poll GET /imports for the job). Key reuse is scoped per credential + path and held for 24h on 2xx (5 min on 4xx, 30 s on 5xx).

422 — Credentials

Past the generic schema case above, the per-provider credential intakes reject a key that fails the provider’s format rules with the check spelled out in error.details. This fires locally — before any round-trip to the provider — so fix the paste and resend; it never becomes a retryable condition.
Twilio’s variant gates on accountSid (must match AC + 32 hex) plus a minimum-length authToken; Klaviyo on apiKey starting pk_; MessageBird on a 20–128 character key with an optional live_/test_ prefix. Branch on error.details.field exactly as for the generic schema branch.

409 — In-flight idempotency conflict

The concurrency branch of the Idempotency-Key contract above: the retry beat the original delivery.
Back off and resend with the same key, or poll GET /api/v1/imports for the job the first delivery created. Never rotate to a new key on this branch — a new key starts a second run. Two sibling 409s live on the terminal-side endpoints: IMPORT_NOT_CANCELLABLE (error.details.current_status tells you which terminal status blocked the cancel) and ALREADY_ROLLED_BACK / IMPORT_STILL_RUNNING on rollback. All three are stable states, not retries.