> ## 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, dedupe, and upsert contacts as the source of truth

> Bulk-import an existing CRM or warehouse contact estate into Devotel Orbit with dedupe-safe semantics, then keep it fresh with idempotent upserts pinned to your source ids, without duplicate rows or double opt-ins.

# Import, dedupe, and upsert contacts as the source of truth

Every new tenant starts with the same engineering task: a contact estate that
already exists somewhere else (a CRM, a commerce backend, a warehouse table)
has to land in Orbit exactly once, and your integration has to keep it current
afterwards. This guide is the API-first path for that task: import with dedupe
semantics on day one, then upsert idempotently forever after, with your system
of record staying the source of truth.

If you would rather click than code, the
[assisted import wizard](/guides/assisted-import-wizard) covers one-off moves
from Twilio, Telnyx, Klaviyo, and MessageBird with a dry-run and rollback. If
your warehouse is the source and the sync is scheduled,
[reverse ETL warehouse exports](/guides/reverse-etl-warehouse-exports) covers
that direction. This page is the pattern both of those tools implement: pin an
external id, upsert, verify.

## 1. The day-one problem

A legacy contact model does not map one-to-one onto Orbit, and naive imports
fail in three predictable ways:

* **Duplicates.** The same person arrives once per source system, or once per
  import run, and you end up with three rows for one customer.
* **Lost lineage.** Without your source id on the Orbit row, every later sync
  becomes a fuzzy match and every update risks creating a new row instead of
  refreshing the old one.
* **Consent drift.** Re-importing a contact can re-trigger welcome messages or
  double opt-ins for people who already consented, which is both a bad first
  impression and a compliance problem you now own.

The fix is a discipline, not a feature flag: every row carries your source id,
every write is an upsert keyed on that id (falling back to email and phone),
and consent is imported as recorded state, never re-asked.

## 2. The model: contact, addresses, consent, journey state

Four layers matter for an import, and they are stored separately on purpose:

| Layer | What it carries | Where it lands |
| - | - | - |
| Contact | Identity and scalar facts: `first_name`, `last_name`, `company`, `country_code`, `timezone`, `language`, `tags`, custom `attributes`, and `external_id`, your source-of-truth pin | `POST /api/v1/contacts` |
| Addresses | Reachable endpoints: phone (normalized to E.164 server-side), email, channel handles. A contact needs at least one of phone or email at creation | Part of the contact payload |
| Consent | Per-contact, per-channel grant or suppression state, with your recorded basis | `POST /api/v1/contacts/{id}/consent` and `/{id}/consent/{channel}` |
| Journey state | Which journeys a contact is enrolled in and where they are in them | Enrollments, created after import |

Two boundaries are worth internalizing before you map columns:

* **Custom fields carry facts, the CDP derives intelligence.** Import the
  scalar CRM facts you personalize and target with, as custom fields and
  attributes (see [custom fields](/guides/custom-fields)). Do not import
  derived values like segments, scores, or churn predictions: those live in
  the CDP profile and are recomputed by Orbit from events
  ([what is a CDP](/concepts/what-is-a-customer-data-platform),
  [computed traits](/guides/computed-traits)). Imported derived values go
  stale on day two; recomputed ones do not.
* **Journey state is not imported.** Journeys enroll contacts by segment or
  list after the import lands ([journeys](/campaigns/journey)). Bring the
  facts and consent that decide enrollment; let Orbit rebuild the journey
  state.

## 3. Choose an import pattern

Pick by cadence, not by tool preference. All three patterns write through the
same upsert semantics, so switching later costs nothing.

| Pattern | Use when | Surface |
| - | - | - |
| Bulk CSV, one-off | A single cut-over of a static list | [Assisted import wizard](/guides/assisted-import-wizard): dry-run, conflict policy (skip, overwrite, merge), rollback |
| Warehouse reverse ETL | The warehouse is the source and the sync is scheduled | [Reverse ETL exports](/guides/reverse-etl-warehouse-exports), emitting dedupe-ready rows (section 7) |
| API per event | Your CRM or backend pushes changes as they happen | The batch upsert loop below, driven by your webhook consumer or change feed |

The wizard is right when a human should eyeball the reconcile table before
cut-over. Reverse ETL is right when the estate changes daily and nobody wants
to be in the loop. The API pattern is right when a row changing in your CRM
should be reflected in Orbit within seconds.

## 4. The idempotent upsert pattern

Three properties make an upsert idempotent on Orbit:

1. **`external_id` pinning.** Set `external_id` to your source-of-truth
   identifier on first write. Orbit keeps at most one live contact per
   `external_id`, so the same pin can never create a second row; a later write
   with the same pin resolves to the existing contact and refreshes it.
2. **Address fallback.** Rows without a pin (or against a source that minted
   a new pin) still resolve to an existing contact by email, then phone, so a
   re-import of the same person updates rather than duplicates.
3. **Request idempotency.** Send an `Idempotency-Key` header with every write
   ([idempotent requests](/guides/idempotent-requests)). Retrying a timed-out
   request under the same key replays the original response instead of
   applying the change twice.

`POST /api/v1/contacts/bulk` accepts up to 10,000 rows per call and answers
with `inserted`, `updated`, and `skipped` counters plus an `import_job_id`.
Re-running an identical batch is safe by construction: existing rows move to
`updated` or `skipped`, never to a second live row.

### Race windows to design around

* **Create races are benign.** Two writers inserting the same person
  concurrently produce one live contact; the loser lands as an update or a
  skip, enforced by the uniqueness on `external_id` and the address fallback.
* **Update races are last-write-wins per field.** Updates are optimistic and
  carry no version token, so two pipelines writing different values to the
  same field concurrently will not merge; the later write wins. Funnel a
  given field through one writer (for example, the warehouse owns
  `attributes.plan`, the event pipeline owns `tags`) instead of racing two.
* **Dedupe inside a batch before sending.** Two rows in one batch that map to
  the same `external_id` is a source bug; collapse them in your export SQL
  (section 7) rather than relying on the API to pick a survivor.
* **Retries need the same key and body.** A retry under a fresh
  `Idempotency-Key` is a new request. Key chunks deterministically (run id
  plus offset, as in the loop below) so a crash mid-run resumes safely.

### Node batch-upsert loop

A dependency-free loop you can run from CI, a cron job, or a webhook consumer.
It chunks, keys every chunk deterministically, backs off on 429 and 5xx, and
fails fast on 4xx so bad rows surface instead of retrying forever.

```js theme={null}
// upsert-contacts.mjs, Node 20+ (global fetch). BASE and KEY come from your
// developer console; see /api-reference/overview for your API base and key scopes.
const BASE = process.env.ORBIT_API_BASE;
const KEY = process.env.ORBIT_API_KEY;
const CHUNK = 1000; // bulk accepts up to 10,000 rows per call

async function post(path, body, headers = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(BASE + path, {
      method: "POST",
      headers: {
        authorization: `Bearer ${KEY}`,
        "content-type": "application/json",
        ...headers,
      },
      body: JSON.stringify(body),
    });
    if (res.status === 429 || res.status >= 500) {
      const wait = Math.min(Number(res.headers.get("retry-after") ?? 2 ** attempt), 30) * 1000;
      await new Promise((r) => setTimeout(r, wait));
      continue;
    }
    if (!res.ok) throw new Error(`${path} failed: ${res.status} ${await res.text()}`);
    return res.json();
  }
}

export async function upsertContacts(rows, runId) {
  const totals = { inserted: 0, updated: 0, skipped: 0, jobIds: [] };
  for (let i = 0; i < rows.length; i += CHUNK) {
    const { data } = await post(
      "/api/v1/contacts/bulk",
      {
        contacts: rows.slice(i, i + CHUNK).map((r) => ({
          external_id: r.crm_id, // the pin: your source-of-truth id
          phone: r.phone, // normalized to E.164 server-side
          email: r.email,
          first_name: r.first_name,
          last_name: r.last_name,
          attributes: { plan: r.plan },
        })),
      },
      { "idempotency-key": `${runId}-chunk-${i}` }, // crash-safe resume
    );
    totals.inserted += data.inserted;
    totals.updated += data.updated;
    totals.skipped += data.skipped;
    totals.jobIds.push(data.import_job_id);
  }
  return totals;
}
```

After the run, reconcile `inserted + updated + skipped` against your source
row count. Anything skipped is downloadable per job from
`GET /api/v1/contacts/import-jobs/{id}/skipped.csv`; fix those rows and resend
them through the same loop. Resending is safe: rows that already landed count
as `updated`, not as new contacts.

## 5. Attach the consent envelope on first write

Import consent as the state your source recorded, not as a fresh opt-in
event. The rule set:

* **Write grants as grants.** For each channel you will use, record the
  consent exactly as your source holds it via
  `POST /api/v1/contacts/{id}/consent/{channel}`. A contacted customer whose
  consent predates Orbit must not receive a "please confirm" message on the
  way in.
* **Write suppression as suppression.** If the source says a person opted
  out, import the suppression too (for list-shaped opt-out data,
  [bulk suppression import](/guides/suppression-csv-bulk-import)). A
  suppressed recipient stays suppressed; send gates honor it on every
  channel.
* **No basis means no consent.** Rows whose source cannot state a consent
  basis land unconsented. If you run
  [double opt-in](/compliance/double-opt-in), those contacts go through your
  confirmation flow once; contacts imported with a recorded grant do not get
  re-asked. That asymmetry is what prevents the double opt-in.
* **You own the evidence.** Consent and its basis are tenant-owned controls
  ([consent management](/compliance/consent-management)): Orbit records and
  enforces the state you declare, and your source system keeps the
  underlying proof ([TCPA consent records](/compliance/tcpa-consent-record-defense)).
  Carry a reference to that proof in a custom field if you want it one hop
  from the contact row.

## 6. Verify before you cut over

1. **Read back.** Sample rows across the run and `GET` them: phone numbers
   came back as E.164, `external_id` matches your source, custom attributes
   carried over.
2. **Reconcile counters.** `inserted + updated + skipped` equals the deduped
   source row count. A mismatch means rows failed validation; pull the
   skipped-rows CSV for each `import_job_id` and fix the input.
3. **Check the timeline.** `GET /api/v1/contacts/{id}/timeline` shows the
   imported contact with its events stitched into one history
   ([contact timeline](/guides/contact-timeline)). A contact whose old
   message history and new attributes both appear is stitched correctly.
4. **Journey smoke test.** Enroll a handful of imported contacts in a test
   journey in [test mode](/guides/sandbox-test-mode) and confirm the
   enrollment and channel gates behave as the imported consent says they
   should (`GET /api/v1/contacts/{id}/journey` reads the enrollment state).
5. **Wrong batch?** A bulk import can be rolled back within 24 hours
   (`POST /api/v1/contacts/imports/{id}/rollback`), removing exactly the
   contacts that job created. Rollback is for bad batches; ordinary churn
   after cut-over is an update, not a rollback.

Only then point your integration's writes at Orbit and decommission the old
path. Keep the source writing through the upsert loop during a parallel
window; identical writes landing twice is precisely the case the pattern
makes harmless.

## 7. Dedupe and migration crossover

Imports prevent duplicates going forward; the
[contact merge guide](/guides/contact-merge) cleans up the estate you already
have. The crossover rules:

* **Scan before you judge.** `POST /api/v1/contacts/duplicates/scan` and
  `GET /api/v1/contacts/duplicates` surface candidate duplicate clusters
  (same person, multiple rows) with their counts;
  [identity resolution walkthrough](/guides/identity-resolution-and-merge-walkthrough)
  covers reading them, and the
  [merge policy](/concepts/contact-merge-policy) explains what a merge keeps.
* **Merge after import, not instead of it.** Import first with pins; then run
  the scan and merge the clusters whose pins disagree (for example, the same
  person under two CRM ids). `POST /api/v1/contacts/merge` combines a cluster
  into one survivor, and `POST /api/v1/contacts/unmerge/{mergeId}` undoes a
  merge that turned out wrong.
* **Never merge to hide a missing pin.** If duplicates keep appearing after
  cut-over, the writer that creates them is skipping `external_id`; fix the
  writer, then merge the residue.

On the warehouse side, emit one survivor row per person so dedupe decisions
happen where the full history lives. This is the shape that feeds the loop
above or a reverse ETL sync:

```sql theme={null}
-- Warehouse-side: collapse to one survivor row per person before export.
-- Orbit still enforces one live row per external_id, but clean input keeps
-- the inserted/updated/skipped counters meaningful.
WITH normalized AS (
  SELECT
    crm_id,
    LOWER(TRIM(email)) AS email,
    REGEXP_REPLACE(phone, '[^0-9+]', '', 'g') AS phone,
    first_name,
    last_name,
    plan,
    updated_at,
    ROW_NUMBER() OVER (
      PARTITION BY COALESCE(
        NULLIF(LOWER(TRIM(email)), ''),
        REGEXP_REPLACE(phone, '[^0-9+]', '', 'g')
      )
      ORDER BY updated_at DESC
    ) AS rn
  FROM raw_contacts
  WHERE deleted_at IS NULL
)
SELECT crm_id AS external_id, email, phone, first_name, last_name, plan
FROM normalized
WHERE rn = 1;
```

The same `COALESCE(email, phone)` partition you use here mirrors the
resolution order Orbit applies on the way in, so what your export calls one
person is what Orbit stores as one contact.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.