Upsert a contact by external id
Idempotent upsert of a contact keyed by a stable, customer-supplied
external id — the canonical "sync my system's truth into SendOps" call.
The external id is a permanent dedup key (unlike the 24-hour
Idempotency-Key window): re-running the same request always converges
to the same contact.
Resolution order: (1) if a contact already carries this external_id it
is updated in place — including changing its email; (2) otherwise, if
the supplied email already exists it is adopted (the external id is
stamped onto it), unless that email already belongs to a different
external id, which conflicts; (3) otherwise a new contact is created
(email is required).
The three conflicts are not interchangeable. Each returns 409 with
its own code, because the correct reaction differs and a client that
retries all three (or gives up on all three) is wrong in two cases out of
three:
code |
Cause | What to do |
|---|---|---|
contact_external_id_mismatch |
Resolution step 2: the email is owned by a contact under a different external id. |
Do not retry unchanged — the incumbent will not move. Repeat with rebind_external_id: true to re-key it in place, or reconcile the two records. incumbent_external_id names the holder; incumbent_archived tells you whether it is an archived (released) identity, the case where re-keying is unambiguously safe. |
contact_email_taken |
Resolution step 1: this contact exists, and the email you are moving it to belongs to another contact. |
Reconcile the two records. rebind_external_id does not help — it opts out of the external-id ownership guard, not this one. |
contact_claim_race |
A concurrent write claimed this external_id mid-resolution. |
Retry the same request. This is the only transient conflict here, and it carries Retry-After. |
An archived contact still owns its address (archiving is not deletion), so
an address released by one identity and re-registered under another
surfaces as contact_external_id_mismatch with
incumbent_archived: true.
attributes and topic_preferences are the desired set. unsubscribe_all
is omitted-means-untouched (a null/absent value never changes the
current opt-out). lists, when present, replaces static-List membership
exactly (an empty array clears all); when absent, membership is unchanged.
Returns 201 on creation and 200 on update.
Also supports the Idempotency-Key header for transport-level retry
de-duplication, independent of the permanent external-id key.
Path parameters
external_id string required The caller's stable identifier for this contact. Opaque, unique per org.
Request body
Content type: application/json
email string<email> optional The contact's address. Required when creating (external id not yet known); when the external id already exists, a different value changes the contact's email in place.
attributes object optional Desired attribute map. Validated against the registry.
unsubscribe_all boolean | null optional Master opt-out. Omitted-means-untouched — a null/absent value never
changes the contact's current opt-out state.
rebind_external_id boolean optional When the email already belongs to a contact carrying a different
external id, the upsert normally returns 409 Conflict rather than
steal that contact from its current identity. Set this to true to
instead rebind the email onto the external id in the path — an
authoritative "I own this identity, re-key it" write that heals a
stale external id in place. Leave false (the default) unless you are
the sole owner of the contact: rebinding overwrites whatever external
id the row currently carries.
Default: false
topic_preferences array<object> optional Each entry in topic_preferences:
topic_name string required The topic's name (see /v1/topics).
subscription_status string enum required One of: OPT_IN, OPT_OUT
lists array<string<uuid>> | null optional Exact static-List membership. A present empty array clears all
memberships; an absent (null) array leaves membership untouched.
Responses
Errors follow the RFC 7807 problem format — see the error reference.
email string<email> required unsubscribe_all boolean required Master opt-out — when true the contact receives no topic email.
attributes object required Custom attribute map as stored in SES. Always an object, never null.
topic_preferences array<object> required Each entry in topic_preferences:
topic_name string required The topic's name (see /v1/topics).
subscription_status string enum required One of: OPT_IN, OPT_OUT
lists array<string<uuid>> required Ids of the static Lists the contact currently belongs to. Always an array.
created_at string<date-time> required updated_at string<date-time> required ses_sync object optional Present ONLY while the contact has not reached the customer's SES account yet; absent means synced. A 2xx on a contact write means SendOps has accepted and durably stored the change, not that SES has it — writes are mirrored afterwards, paced against the AWS account-wide budget of one contact request per second. Everything user-visible (segments, consent filtering, exports, sends) reads SendOps' own copy, so a pending mirror holds nothing back.
email string<email> required unsubscribe_all boolean required Master opt-out — when true the contact receives no topic email.
attributes object required Custom attribute map as stored in SES. Always an object, never null.
topic_preferences array<object> required Each entry in topic_preferences:
topic_name string required The topic's name (see /v1/topics).
subscription_status string enum required One of: OPT_IN, OPT_OUT
lists array<string<uuid>> required Ids of the static Lists the contact currently belongs to. Always an array.
created_at string<date-time> required updated_at string<date-time> required ses_sync object optional Present ONLY while the contact has not reached the customer's SES account yet; absent means synced. A 2xx on a contact write means SendOps has accepted and durably stored the change, not that SES has it — writes are mirrored afterwards, paced against the AWS account-wide budget of one contact request per second. Everything user-visible (segments, consent filtering, exports, sends) reads SendOps' own copy, so a pending mirror holds nothing back.
PUT /v1/contacts/by-external-id/{external_id}. Nothing was
written. The code says which of three unrelated conflicts occurred, and
they require different reactions — see the endpoint description for the
full table:
- contact_external_id_mismatch — the email belongs to a contact under a
different external id. Permanent until reconciled; retry only with
rebind_external_id: true. Carries incumbent_external_id and
incumbent_archived.
- contact_email_taken — the email you are renaming this contact onto
belongs to another contact. Permanent; rebind_external_id does not
apply. Carries incumbent_archived, and incumbent_external_id when the
holder has one.
- contact_claim_race — a concurrent write claimed this external id.
Transient; retry, honouring Retry-After.
application/problem+json code is internal_error. The
request_id field can be quoted to SendOps support to investigate.
application/problem+json code is
upstream_unavailable; the status is 503 when the call failed and
504 when it exceeded our deadline.
Retryable. Always accompanied by Retry-After. On a 504 the
outcome is genuinely unknown (the write may have reached SES), so
retries of a non-idempotent call should carry an Idempotency-Key.
The detail string is deliberately generic: the underlying AWS error
describes the customer's own account and is recorded in SendOps logs
against the request_id, not returned here.
application/problem+json code is
upstream_unavailable; the status is 503 when the call failed and
504 when it exceeded our deadline.
Retryable. Always accompanied by Retry-After. On a 504 the
outcome is genuinely unknown (the write may have reached SES), so
retries of a non-idempotent call should carry an Idempotency-Key.
The detail string is deliberately generic: the underlying AWS error
describes the customer's own account and is recorded in SendOps logs
against the request_id, not returned here.
application/problem+json