Skip to main content

Import and reconcile contacts

This guide walks the bulk contact-import lifecycle over the API: prepare a CSV, dry-run it with the preview endpoint, run it as a background job with a merge strategy, poll for progress, download the rows that were skipped, and cancel or roll back a job that went wrong. The same flow backs the dashboard’s Audience → Imports page — the API and the UI drive the same jobs. For request and response schemas, see the contacts API reference. This page covers the workflow; custom fields covers defining the fields a CSV can map into, and opt-out lists covers how consent is enforced after the import lands — linked rather than repeated below.

1. When to import — and when not to

Reach for a bulk import when you are moving a finite roster into Orbit:
  • Platform migrations — the contact export from your old provider.
  • CRM exports — a HubSpot or Salesforce list pulled to CSV.
  • Seed lists — a one-off event sign-up sheet or a purchased list you have consent for (see step 7).
Do not use an import as a sync mechanism. If your source of truth lives in a CRM and changes daily, a nightly re-import of the whole file drifts and duplicates work — use the HubSpot/Salesforce integration or the contacts API to upsert on change instead. And if you only need to select contacts you already have, that is a segment, not an import — segments evaluate live at send time and never copy data.

2. Prepare the CSV

Every row needs at least one address: a phone or an email. Rows with neither are skipped during the import. Beyond that, map columns to contact field keys: Keep the file to ≤32 columns and ≤2KB per cell when you want the original row echoed back in the skipped-rows CSV — oversized rows import fine but their raw form is truncated in the report. Two sizes of import exist:
  • Up to 10,000 rows — the synchronous POST /api/v1/contacts/bulk endpoint answers in one request.
  • Up to 1,000,000 rows — the asynchronous POST /api/v1/contacts/imports job this guide covers.

3. Preview before committing

Always dry-run first. POST /api/v1/contacts/imports/preview accepts the same row shape as the real import (up to 10,000 rows for the preview) and writes nothing:
The response tells you three things before any data moves:
  • validation — how many rows carry a phone, how many carry an email, how many are invalid, and a sample of the specific errors.
  • duplicates.existing_db_match_count — how many rows match a contact already in Orbit (by phone or email). These are exactly the rows your merge strategy (step 4) will act on.
  • duplicates.in_csv_dup_count — duplicate addresses within the file itself, with dup_match_field naming the field that collided and the first row it was seen at. Deduplicate locally before you import; the job handles them, but a clean file is cheaper to reason about.
If rows_invalid or in_csv_dup_count is high, fix the export at the source and re-preview. Running the preview on a 2,000-row sample of a million-row file is a fair proxy — header mistakes and formatting drift show up in the first page.

4. Run the async import

Post the rows mapped to field keys, with a merge strategy and the original file name for auditing:
The endpoint returns 202 with the job_id — the job now runs in the background. A 503 here means the import queue is unavailable; fall back to the synchronous POST /api/v1/contacts/bulk for files under 10,000 rows, or retry later. Only contacts the job created are ever counted as the job’s output — merge rows update existing records and are never owned by the job, which matters when you roll back (step 6).

5. Poll for progress and collect skipped rows

Poll GET /api/v1/contacts/imports/{id} until status leaves pending/processing:
Statuses are pendingprocessing → one of completed, failed, or cancelled. Poll every few seconds; there is no push channel for these jobs. When the job finishes with failed_rows > 0 — or a synchronous bulk import reports skips — download the skipped-rows CSV, keyed by the job id. For async jobs:
The file is served as an attachment with a row_index column, a human-readable reason column, and one column for every header the original upload contained:
Open it alongside the source file, fix the flagged rows, and re-import just those rows as a new job.

6. Cancel and roll back

Two different controls — cancel stops a job that is still running; rollback deletes what a finished job created. Cancel stamps a request the worker checks between batches, so in-flight work stops early and the job flips to cancelled:
Cancellation is best-effort: rows already inserted stay. A 409 means the job already completed, failed, or was cancelled — or the id is wrong. Rollback hard-deletes every contact the job created, cascading through list memberships, segments, scores, engagement profiles, and consent records in one transaction:
Rollback is available for 24 hours after the job finishes (completed, cancelled, or failed — the job response’s rollback_until timestamp says exactly until when). The window exists so an old import whose contacts have accumulated downstream data cannot be silently orphaned. A 409 means the import was already rolled back or the window expired. Rollback only removes what the job created; rows a merge import updated keep their pre-existing records. The combination for a botched import is cancel → let it settle → rollback. After a successful import:
  1. Spot-check in the dashboard under Audience → Imports — pick the job, preview a handful of the imported contacts, and confirm field mapping landed where you expected (custom fields especially).
  2. Segment, don’t re-import. If the import needs slicing — “everyone from the Q1 file with plan_tier = enterprise” — build it as a segment over the imported contacts.
  3. Importing an address is not consent. Suppression and opt-out handling is enforced at send time, not at import time, so a row landing in Orbit does not mean it is reachable. Capture consent with a public consent form or import it into an opt-out list before the first campaign — see opt-out lists. US SMS sends additionally gate on carrier registration (10DLC).

8. Troubleshooting

  • failed_rows climbs during the job. Wait for completion, download the skipped-rows CSV, fix the flagged rows, and re-import them as a new job. The reason column names the exact cause per row.
  • Preview shows existing_db_match_count higher than expected. The file overlaps contacts already in Orbit; decide the merge strategy deliberately — skip keeps the existing record, merge overwrites its fields from the CSV.
  • Duplicates inside the file. in_csv_dup_count catches repeated phones or emails within the upload; deduplicate at the source.
  • Rollback returns 409. The 24-hour rollback_until window expired or the job was already rolled back. From here the contacts are ordinary data — delete them with POST /api/v1/contacts/bulk-delete instead.
  • Cancel returns 409 but contacts kept arriving. The job had already finished between your last poll and the cancel call; check status and use rollback within the window.
  • Imported contacts don’t receive the campaign. Consent is gated at send time: check the suppression cohort in the campaign dry-run response and the opt-out lists the audience funnels through. An imported address without consent record stays unreachable for marketing sends.

See also