> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# First contact import: the end-to-end tutorial

> Walk one CSV or CRM export through the full import loop — export, format and dedupe keys, submit the import job, poll to completion, read the imported/skipped counters, fix rejected rows, re-import, and know when rollback is available.

# 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](/guides/import-and-migrate-contacts)), the per-endpoint deep dive ([import and reconcile contacts](/guides/import-contacts)), and the operations references link out from a complete loop rather than a pile of options.

Have an API key with `contacts:write` scope before you start — see [API keys](/authentication). 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](/guides/platform-migration-jobs) 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.

Two non-import cases: if the CRM stays the system of record and changes daily, use the [HubSpot/Salesforce integration](/guides/hubspot-salesforce-integration) to sync instead of re-importing files. And if you already have the contacts and only need to select some of them, that is a [segment](/guides/cdp-segments), not an import.

## 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** — a `phone` 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.

| Field                                       | Expected shape                                                                                                                                     |
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `phone`                                     | E.164 preferred (`+14155552671`). Whitespace, punctuation, and PBX extensions (`x1234`) are normalized; a row the server cannot parse is rejected. |
| `email`                                     | A valid email address.                                                                                                                             |
| `first_name` / `last_name` / `display_name` | Any subset.                                                                                                                                        |
| `country_code`, `timezone`, `language`      | Locale — drives quiet-hours and send-window enforcement.                                                                                           |
| `tags`                                      | Comma-separated values.                                                                                                                            |
| Any custom-field key                        | Define the field first in [custom fields](/guides/custom-fields); a value that fails its type check rejects the row.                               |

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.

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/contacts/imports/preview" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "rows": [
      { "phone": "+14155552671", "email": "ada@example.com", "first_name": "Ada", "last_name": "Lovelace" },
      { "phone": "+14155552671", "email": "grace@example.com", "first_name": "Grace" }
    ]
  }'
```

Read three numbers out of the response: how many rows are invalid (with a sample of the specific errors), how many match an existing Orbit contact by phone or email, and how many duplicate *inside the file*. If invalid or in-file-duplicate counts are high, fix the export and re-preview.

When the preview is clean, submit the real job with a merge strategy:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/contacts/imports" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "file_name": "crm-export-2026-q1.csv",
    "merge_strategy": "merge",
    "rows": [
      { "phone": "+14155552671", "email": "ada@example.com", "first_name": "Ada", "last_name": "Lovelace" }
    ]
  }'
```

```json theme={null}
{
  "data": {
    "job_id": "import_01HZX9EXAMPLE",
    "total_rows": 48200,
    "status": "pending"
  }
}
```

Save the `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 leaves `pending`/`processing`:

```bash theme={null}
while true; do
  status=$(curl -s "https://api.orbit.devotel.io/api/v1/contacts/imports/import_01HZX9EXAMPLE" \
    -H "X-API-Key: $ORBIT_API_KEY" | grep -o '"status":"[^"]*"')
  echo "$status"
  case "$status" in
    *completed*|*failed*|*cancelled*) break ;;
  esac
  sleep 3
done
```

Statuses move `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 are `total_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`:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/contacts/import-jobs?limit=10" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

Where did each row end up:

* **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 = total` is 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.

When `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:

```bash theme={null}
curl -OJ "https://api.orbit.devotel.io/api/v1/contacts/import-jobs/import_01HZX9EXAMPLE/skipped.csv" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

```
row_index,reason,phone,email,first_name
3,Invalid phone number,15551234,ada@example.com,Ada
7,Missing mandatory custom field: region,+14155552671,grace@example.com,Grace
```

Sort the `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 is `Invalid phone number` — every one exported as `14155552671` or `415-555-2671 x12` and none of them normalized. The loop for that one class:

1. **Extract.** Pull just those rows out of the skipped-rows CSV (`row_index` points back into the original file).
2. **Fix.** Normalize the phone column to E.164 — add the missing `+1` country 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.
3. **Re-preview.** Run the fixed rows through `POST /contacts/imports/preview` and confirm the invalid count is now zero.
4. **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.
5. **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.

Do the whole loop per class; a file with three rejection classes takes three small re-imports, not one blind retry.

## 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](/troubleshooting/import-jobs) — 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 = total` reconciled.
* 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](/guides/opt-out-lists).

## See also

* [Import or migrate contacts: pick the right surface](/guides/import-and-migrate-contacts) — the decision guide between wizard, async jobs, CDP ingest, and migration connectors
* [Import and reconcile contacts](/guides/import-contacts) — the per-endpoint deep dive for the API used here
* [Troubleshooting: contact imports](/troubleshooting/import-jobs) — cancel/rollback semantics and per-code fixes
* [Contacts API reference](/api-reference/endpoints/contacts) — full request/response schemas
* [Custom fields](/guides/custom-fields) — define the typed fields your headers map into
* [Opt-out lists](/guides/opt-out-lists) — consent enforcement before the first send
