Contact create conflicts: duplicate and archived contacts
Phone numbers and email addresses are unique per tenant: one live contact may hold a given phone number or email at a time. When a create collides with a record you already have,POST /api/v1/contacts answers 409 with a
typed code, and the dashboard’s create-contact dialog interrupts the flow
with recovery UI instead of a generic failure. When an update tries to move
an archived contact without an explicit confirmation, the API answers 409
the same way. This page explains what each code means and the shortest path
past it.
What each 409 means
CONTACT_EMAIL_ALREADY_EXISTS (409)
A live contact in this tenant already stores this email address. The
comparison is case-insensitive and ignores surrounding whitespace, so
Foo@Example.com collides with foo@example.com; re-typing the address in
a different casing never creates a second contact. The response details
name the colliding field and, when the existing record could be resolved,
its id in existing_contact_id.
CONTACT_PHONE_ALREADY_EXISTS (409)
The same gate on the phone number. The comparison ignores formatting
(spaces, dashes, parentheses, leading zeros), so +1 555 0100 and
+15550100 are the same identifier.
CONTACT_WHATSAPP_BSUID_ALREADY_EXISTS (409)
The same gate on the WhatsApp identifier attached to a contact. Another
contact already carries it, so the new record cannot.
CONTACT_ARCHIVED_REQUIRES_UNARCHIVE (409)
An update tried to move a contact out of the archived lifecycle stage
without an explicit confirmation. Archiving is how you retire a record
without deleting it, and Orbit refuses an implicit unarchive so a bulk
update or a stale script cannot silently restore archived contacts into
your live audience. To move an archived contact to any other stage, send
the same update with "unarchive": true alongside the lifecycle_stage
change.
What you see in the dashboard
The create-contact dialog checks for duplicates before you ever submit, and again when the API enforces the uniqueness gate:- Step 1 duplicate check. When you press Next on the Basic Info step, the dialog searches your existing contacts for an exact match on the phone number or email you entered. If one matches, the dialog stops on step 1 and shows a banner naming the colliding identifier: “A contact with this email already exists. Change the email to continue, or close this dialog to edit the existing contact.” The check is best-effort; the server-side gate below stays authoritative.
- Submit-time banner and toast. If the create still reaches a 409 (for example the identifier was taken between the check and the submit), the dialog shows a persistent banner, “A contact with this email already exists. Open the existing contact from the toast, or change the email and try again.”, together with a toast titled Duplicate email or Duplicate phone number. When the API identified the conflicting record, the toast carries a View existing action that opens that contact’s page, so you can update the record you have instead of re-creating it.
- Editing clears the warning. Changing the phone number or email clears the banner and re-enables the flow, so you can retry with the corrected identifier.
- Archived contacts. When an edit would move an archived contact to
another stage, the dashboard asks you to confirm the unarchive before the
change applies; confirming sends the
unarchive: trueconfirmation for you. If you seeCONTACT_ARCHIVED_REQUIRES_UNARCHIVEfrom a script or integration instead, add the flag yourself (see below).
Resolve a conflict over the API
-
Read the 409.
details.fieldtells you which identifier collided (phone,email, orwhatsapp_bsuid). When the API resolved the conflicting record,details.existing_contact_idis its id and you can skip the lookup. -
Fetch the conflicting contact. Without an id, look the record up by
identifier with
GET /api/v1/contacts(thesearchquery parameter), the same lookup the dashboard uses:Remember the email comparison is case-insensitive: a409onFoo@Example.commeans a contact storingfoo@example.comexists. -
Update instead of create. Open the returned contact and update it
with
PATCH /api/v1/contacts/{id}rather than minting a second record. If both records describe the same person and you want one canonical profile, follow the merge and identity-resolution semantics in CDP identity resolution. -
Unarchive before editing an archived record. A
PATCHthat setslifecycle_stageon an archived contact is refused until you confirm:Retrying the same PATCH withoutunarchive: truefails identically; the flag is the confirmation, not a different endpoint.
Decision table
Related
- CDP identity resolution — how identifiers map to one canonical contact, and merge semantics.
- ALREADY_EXISTS — the generic family these codes belong to.
- Error codes — the full generated registry.