First contact import: the end-to-end tutorial
This tutorial walks one CSV or CRM export through the full loop: decide to import, shape the file, dry-run a preview, submit the job, poll it to completion, read the result, fix the rejected rows, and re-import them. After this one walkthrough, the decision guide (import or migrate contacts), the per-endpoint deep dive (import and reconcile contacts), and the operations references link out from a complete loop rather than a pile of options. Have an API key withcontacts:write scope before you start — see API keys. Set it as ORBIT_API_KEY in your shell so every call below is copy-pasteable.
1. Decide: import, migrate, or sync
Import is the right move in three situations:- First tenant setup — your contact roster lives in your old provider or a spreadsheet, and Orbit is the new system of record.
- Replatforming — a one-time lift of contacts off a system you are leaving. (For Twilio, Telnyx, Klaviyo, MessageBird, or Front, the connector-based platform migration wizard replaces this tutorial entirely.)
- CRM drift cleanup — a point-in-time reconciliation pass: export from the CRM, import with the right merge strategy, and the tenant catches up to the file.
2. Shape the file: format and dedupe keys
Export to CSV (or read the CRM rows into JSON objects directly). Every row needs at least one address — aphone or an email — because addresses are the dedupe keys: a row whose phone or email matches an existing contact is a duplicate, and the merge strategy you pick decides what happens to it.
Dedupe as close to the source as you can. Run the file through
sort | uniq (or your CRM’s dedupe view) before upload — duplicates left in the file still land, but a clean file is cheaper to reason about. The preview in step 3 counts what is left: in-file collisions on phone or email, and rows that match contacts already in Orbit. Two size classes: the synchronous POST /api/v1/contacts/bulk takes up to 10,000 rows in one request; the async import job below takes up to 1,000,000.
3. Preview, then submit the job
Dry-run first — the preview accepts the same row shape as the real import and writes nothing. Fix whatever it flags before submitting.job_id. The merge strategy is the whole decision about duplicates: skip (the default) leaves an existing contact untouched; merge overwrites the existing contact’s fields from the row. For a CRM re-import that refreshes attributes, merge is usually what you want; for a fresh acquisition list where the existing record wins, skip. A 503 here means the import queue is unavailable — safe to retry, and files under 10,000 rows can fall back to POST /api/v1/contacts/bulk.
4. Poll to completion
The job runs in the background; there is no push channel, so poll every few seconds until the status leavespending/processing:
pending → processing → {completed, failed, cancelled}. A failed job still imported every valid row up to the failure — it is a partial result, not a lost one. An overnight export pattern is submit-then-watch: keep the list of recent jobs open with GET /api/v1/contacts/import-jobs and check each job’s counters as it lands.
5. Read the result: counters and the rejection report
Three counters do the reconciliation math after every import. On the polled job they aretotal_rows, processed_rows, failed_rows; on the history list (GET /api/v1/contacts/import-jobs) the same three appear as total_rows, imported_rows, skipped_rows:
- Newly imported — a new contact, or an updated existing one under
merge. - Skipped at write — plus the in-file duplicates the preview already counted.
imported + skipped = totalis the consistency check; if it does not hold, something in the run needs attention. - Rejected — the row never became a contact, for the specific reason in the report below.
failed_rows (or skipped_rows) is nonzero, download the rejection report — one row per rejected input row, with the original columns and one reason each:
reason column to build the rejection histogram yourself — each distinct reason string is one class of problem: phone values that could not be normalized, values that failed a custom field’s type check, missing required fields, and duplicate keys. Fix classes one at a time; do not re-run the whole file hoping a class clears.
6. Worked failure loop: one rejected class, fixed and re-imported
Say step 5’s report holds 41 rows whose reason isInvalid phone number — every one exported as 14155552671 or 415-555-2671 x12 and none of them normalized. The loop for that one class:
- Extract. Pull just those rows out of the skipped-rows CSV (
row_indexpoints back into the original file). - Fix. Normalize the phone column to E.164 — add the missing
+1country code, or strip the PBX extension the parser can accept but this export format confused. Fix nothing else in the same pass; a class at a time keeps the re-import diff reviewable. - Re-preview. Run the fixed rows through
POST /contacts/imports/previewand confirm the invalid count is now zero. - Re-import. Submit the fixed rows as a new job with the same merge strategy. Duplicates from the first run resolve through the dedupe keys — a phone that landed the first time and also appears in the fixed file follows the merge strategy instead of forking a second contact.
- Re-read. Total the counters across both jobs: first-run imported + fix imported + remaining rejected = the original file. When only the genuinely unfixable rows (a bad number, a missing required value) remain as rejected, the loop is done.
7. Rollback: the escape hatch and its window
A finished job can be rolled back —POST /api/v1/contacts/imports/{id}/rollback hard-deletes every contact the job created, cascading through list memberships, segments, scores, and consent records in one transaction, and it is available for 24 hours after the job finishes. The semantics, the per-status cancel/rollback matrix, and every 409 code (IMPORT_NOT_CANCELLABLE, IMPORT_STILL_RUNNING, ALREADY_ROLLED_BACK, ROLLBACK_WINDOW_EXPIRED) live in troubleshooting: contact imports — link, don’t repeat. One thing worth knowing up front: rollback only removes what the job created; rows that merge updated keep their pre-existing records, which is why a clean re-import with merge is the preferred fix for a bad attribute pass.
What you should have now
- One import job (plus one re-import job per fixed rejection class) in Audience → Imports, with
imported + skipped = totalreconciled. - The rejected rows fixed at source, re-imported, and down to the genuinely unfixable remainder.
- Consent handled before the first campaign: importing an address is not consent, so imported rows land reachable only after the suppression and opt-out checks that run at send time — see opt-out lists.
See also
- Import or migrate contacts: pick the right surface — the decision guide between wizard, async jobs, CDP ingest, and migration connectors
- Import and reconcile contacts — the per-endpoint deep dive for the API used here
- Troubleshooting: contact imports — cancel/rollback semantics and per-code fixes
- Contacts API reference — full request/response schemas
- Custom fields — define the typed fields your headers map into
- Opt-out lists — consent enforcement before the first send