Upsert a contact by external id

Search Documentation

Search across all developer documentation

contacts

Upsert a contact by external id

PUT /v1/contacts/by-external-id/{external_id}
Auth required api.contacts.manage

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 MERGE: a name you send is written, a name you do not send keeps its current value, and an empty map changes nothing. There is no way to remove an attribute through this field — sending null as a value drops that entry rather than clearing it. topic_preferences, when present, are the desired set (an empty array clears them); when absent, the contact's stored topic choices are unchanged. unsubscribe_all is omitted-means-untouched (a null/absent value never changes the current opt-out). lists takes two shapes: an array replaces static-List membership exactly (an empty array clears all), so lists and attributes can sit side by side with OPPOSITE semantics; an object {"add":[…],"remove":[…]} (the directive POST and PATCH take) changes only the Lists it names. A sync that owns one List should send {"lists":{"add":["<key>"]}} so it never evicts the contact from your other Lists. When lists is absent or null, 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.

Query parameters

strict boolean optional

Opt out of attribute auto-registration. With strict=true an attribute name your org has never used is 422 attribute_validation_failed and nothing is registered — the behaviour every /v1 contact write had before this parameter existed. Absent or false registers the name as a string attribute and reports it in attributes_registered. A value that is not a boolean is 422 validation_failed, rather than being read as false: a caller who sent strict=yes believing they had opted in, and then had an attribute registered anyway, would have been told nothing.

Request body

Content type: application/json

consent string enum optional

How the write treats the contact's existing consent. overwrite (default) stores what the write says. never_relax applies only changes that make consent stricter: an unsubscribe_all true→false is ignored (false→true applies), and a topic OPT_OUT→OPT_IN is ignored (anything→OPT_OUT applies). OPT_OUT is the effective preference: a contact with no preference on a topic whose default is OPT_OUT is not opted in either. A topic the write does not name keeps its preference — even on PUT, which otherwise replaces the set. A contact being created has nothing to relax, so everything applies. Attributes and lists are unaffected. What was ignored is returned in consent_skipped. Use it when the system you sync from does not know about unsubscribes.

One of: overwrite, never_relax

Default: overwrite

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

Attributes to MERGE into the contact's map. A name you send is written; a name you do not send keeps its current value; an empty map changes nothing. This field cannot REMOVE an attribute — a null value is dropped, not applied as a clear. Note this differs from lists on the same body, which replaces membership exactly when it is an array. Validated against the registry. An attribute name your org has never used is REGISTERED as a string attribute and named in the response's attributes_registered. Send strict=true to opt out: an unknown key is then 422 attribute_validation_failed and nothing is registered. Regardless of strict: a value that does not fit an EXISTING attribute's declared type, a name that is not a valid registry identifier, a reserved name, and a direct write to a derived (formula) attribute are all 422 attribute_validation_failed with a per-attribute errors[] array.

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> | object | null optional

Static-List membership, in one of two shapes: - an array — exact membership. The contact ends up in exactly these Lists; any other List it was in is left. A present empty array clears all memberships. - an object — the V1ListsDirective POST and PATCH take. add and remove change only the Lists they name; every other membership is kept. set, when present, behaves like the array. Use {"add":["<key>"]} from a sync that owns one List, so a contact is never taken out of Lists the sync knows nothing about. Each entry is a List key (the org-unique handle, e.g. beta-cohort) or the List's id — the same pair /v1/lists/{id} resolves; an entry that names no static List is a 422 validation_failed and nothing is written. Absent or null leaves membership untouched. Any other shape (a string, a number, an object with other keys) is a 422 validation_failed on lists.

Fields of lists:

add array<string> optional

List keys (or ids) to add the contact to.

remove array<string> optional

List keys (or ids) to remove the contact from.

set array<string> optional

When present, replaces the contact's static-List membership exactly. An empty array clears all memberships. Mutually exclusive with the add/remove delta.

Responses

Errors follow the RFC 7807 problem format — see the error reference.

200 Contact updated application/json
consent_skipped array<object> optional

Present only when the write used consent: "never_relax" and ignored a change that would have relaxed the contact's consent.

Each entry in consent_skipped:

field string required

unsubscribe_all or topic:<name>.

requested string required

What the write asked for (false, or OPT_IN).

kept string required

What the contact still has (true, or OPT_OUT).

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. Ids, not keys — GET /v1/lists maps one to the other, and either form is accepted on the way in.

attributes_registered array<string> required

Attribute names this call added to your org's attribute registry, each registered as a string attribute. Always present; [] when the call registered nothing, and always [] when the request set strict=true. Reported rather than silent because registering a definition changes your audience schema: the name becomes an attr.<name> a segment can filter on, and a typo becomes a permanent attribute nobody asked for. Rename an unwanted one with PUT /v1/attributes/{id}.

created_at string<date-time> required
updated_at string<date-time> required
ses_absent_since string<date-time> optional

When the customer's SES contact list stopped reporting this contact. Set only for the workspace that owns the SES contact list. The contact is still active and mailable in SendOps; omitted when it is on the list, or was never on it.

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.

201 Contact created application/json
consent_skipped array<object> optional

Present only when the write used consent: "never_relax" and ignored a change that would have relaxed the contact's consent.

Each entry in consent_skipped:

field string required

unsubscribe_all or topic:<name>.

requested string required

What the write asked for (false, or OPT_IN).

kept string required

What the contact still has (true, or OPT_OUT).

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. Ids, not keys — GET /v1/lists maps one to the other, and either form is accepted on the way in.

attributes_registered array<string> required

Attribute names this call added to your org's attribute registry, each registered as a string attribute. Always present; [] when the call registered nothing, and always [] when the request set strict=true. Reported rather than silent because registering a definition changes your audience schema: the name becomes an attr.<name> a segment can filter on, and a typo becomes a permanent attribute nobody asked for. Rename an unwanted one with PUT /v1/attributes/{id}.

created_at string<date-time> required
updated_at string<date-time> required
ses_absent_since string<date-time> optional

When the customer's SES contact list stopped reporting this contact. Set only for the workspace that owns the SES contact list. The contact is still active and mailable in SendOps; omitted when it is on the list, or was never on it.

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.

401 Missing, malformed, or unknown API key application/problem+json
402 The organization's plan does not allow this (code: plan_limit_exceeded). limit_type names the limit — a usage axis such as contacts, segments or active_workflows, or a feature the plan does not include (inboxes, platform_profile, audit_log, notification_presets). upgrade_url is the dashboard page that lifts it; overage_available says a Business organization can lift it by switching overage on instead. Retrying will not succeed until the plan changes or usage drops below the limit. application/problem+json
403 Key lacks the required scope or plan limit violated application/problem+json
409 Returned by 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
422 A query parameter, path value or body field failed validation. The body is a validation_failed Problem. When the refusal is about a reference — a segment key, template slug, topic or attribute name the organization does not have — it additionally carries validation_code, field, line/column, missing, candidates and next_step, so a client can correct the call without a second round of guessing. See ValidationProblem. application/problem+json
429 Per-org rate limit exceeded application/problem+json
500 Unexpected server-side failure. The code is internal_error. The request_id field can be quoted to SendOps support to investigate. application/problem+json
503 An upstream dependency — the AWS SES account connected to this org — refused, throttled, or did not answer in time. The 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
504 An upstream dependency — the AWS SES account connected to this org — refused, throttled, or did not answer in time. The 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