Skip to main content

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 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 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. Four layers matter for an import, and they are stored separately on purpose: 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). 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, 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). 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. 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). 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.
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. 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). 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, 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): Orbit records and enforces the state you declare, and your source system keeps the underlying proof (TCPA consent records). 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). 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 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 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 covers reading them, and the 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:
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.