> ## 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.

# Import and reconcile contacts: CSV jobs, merge, rollback

> Bulk-load contacts from a CSV export — map fields, dry-run a preview, run the async import with a merge strategy, poll progress, cancel or roll back, and handle skipped rows.

# 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](/api-reference/endpoints/contacts). This page covers the workflow; [custom fields](/guides/custom-fields) covers defining the fields a CSV can map into, and [opt-out lists](/guides/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](/guides/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](/guides/cdp-segments), 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:

| CSV column                 | Field key                                 | Notes                                                                                                                                                                                                         |
| -------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Phone                      | `phone`                                   | E.164 preferred; the server tolerates whitespace, punctuation, and PBX-extension suffixes (`x1234`, `;ext=1234`). Rows it cannot parse land in the skipped-rows CSV.                                          |
| Email                      | `email`                                   | Must be a valid email address.                                                                                                                                                                                |
| Name                       | `first_name`, `last_name`, `display_name` | Any subset.                                                                                                                                                                                                   |
| Company                    | `company`, `job_title`                    | Optional free text.                                                                                                                                                                                           |
| Locale                     | `country_code`, `timezone`, `language`    | Used for quiet-hours and send-window enforcement.                                                                                                                                                             |
| Tags                       | `tags`                                    | Comma-separated values become the tag array.                                                                                                                                                                  |
| Anything business-specific | a custom-field key                        | Define the field first — see [custom fields](/guides/custom-fields). Values are typed and validated on write; a row that fails a field's validator is skipped, so run the preview (step 3) before committing. |

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:

```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" }
    ]
  }'
```

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:

```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": "q1-newsletter.csv",
    "merge_strategy": "merge",
    "rows": [
      { "phone": "+14155552671", "email": "ada@example.com", "first_name": "Ada", "last_name": "Lovelace" }
    ]
  }'
```

```json theme={null}
{
  "data": {
    "job_id": "import_01HZX9IMPORT",
    "total_rows": 5000,
    "status": "pending"
  }
}
```

| Field            | Notes                                                                                                                                                                                                                                                                 |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `rows`           | 1 to 1,000,000 rows, each a map of field keys to string values.                                                                                                                                                                                                       |
| `file_name`      | Original upload name, retained for auditing. Optional.                                                                                                                                                                                                                |
| `merge_strategy` | `skip` (default) leaves a matching existing contact untouched. `merge` updates the existing contact's fields with the row's values. Choose `merge` for CRM re-imports that refresh attributes; choose `skip` for acquisitions where an existing customer record wins. |

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

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/contacts/imports/import_01HZX9IMPORT" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

```json theme={null}
{
  "data": {
    "id": "import_01HZX9IMPORT",
    "status": "processing",
    "total_rows": 5000,
    "processed_rows": 3200,
    "failed_rows": 4,
    "started_at": "2026-02-02T12:00:05.000Z",
    "completed_at": null,
    "cancel_requested_at": null,
    "errors": []
  }
}
```

Statuses are `pending` → `processing` → 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:

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

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:

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

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

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/contacts/imports/import_01HZX9IMPORT/cancel" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

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:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/contacts/imports/import_01HZX9IMPORT/rollback" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

```json theme={null}
{
  "data": {
    "id": "import_01HZX9IMPORT",
    "deleted_count": 4980,
    "cascade_counts": {
      "contacts": 4980,
      "segment_members": 120,
      "list_members": 340,
      "scores": 4980,
      "engagement_profiles": 4980,
      "consent_records": 4980
    },
    "rolled_back_at": "2026-02-02T12:05:00.000Z"
  }
}
```

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.

## 7. Verify the audience and pair it with consent

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](/guides/cdp-segments) 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](/guides/public-consent-form) or import it into an opt-out list before the first campaign — see [opt-out lists](/guides/opt-out-lists). US SMS sends additionally gate on carrier registration ([10DLC](/guides/10dlc-registration)).

## 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

* [Send a campaign end-to-end](/guides/campaign-end-to-end) — audiences, dry-run, launch, and measurement after the import lands
* [Segments](/guides/cdp-segments) — slice imported contacts without re-importing
* [Custom fields](/guides/custom-fields) — define the typed fields a CSV maps into
* [Opt-out lists](/guides/opt-out-lists) — consent and suppression enforcement at send time
* [Collect consent with a public form](/guides/public-consent-form) — gather opt-in before the first send
* [Contacts API reference](/api-reference/endpoints/contacts) — full request/response schemas for the import endpoints
