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.
2. The model: contact, addresses, consent, journey state
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:external_idpinning. Setexternal_idto your source-of-truth identifier on first write. Orbit keeps at most one live contact perexternal_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.- 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.
- Request idempotency. Send an
Idempotency-Keyheader 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_idand 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 ownstags) instead of racing two. - Dedupe inside a batch before sending. Two rows in one batch that map to
the same
external_idis 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-Keyis 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.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). 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
- Read back. Sample rows across the run and
GETthem: phone numbers came back as E.164,external_idmatches your source, custom attributes carried over. - Reconcile counters.
inserted + updated + skippedequals the deduped source row count. A mismatch means rows failed validation; pull the skipped-rows CSV for eachimport_job_idand fix the input. - Check the timeline.
GET /api/v1/contacts/{id}/timelineshows 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. - 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}/journeyreads the enrollment state). - 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.
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/scanandGET /api/v1/contacts/duplicatessurface 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/mergecombines a cluster into one survivor, andPOST /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.
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.