Create or replace a contact
Full create-or-replace: if the contact exists it is fully replaced; if
it does not exist it is created. Returns 201 on creation and 200
on update.
"Replace" governs the contact row, NOT the attribute map: omitted
topic_preferences revert to the list default and unsubscribe_all is
set to the supplied value, but attributes MERGE — a name you do not
send keeps its current value, and an omitted or empty map clears
nothing.
lists, when present, replaces the contact's static-List membership
exactly (an empty array clears all memberships). When lists is
absent, list membership is left unchanged.
Supports the Idempotency-Key header.
Path parameters
email string<email> required Recipient address. Percent-encode @ as %40. Matched case-insensitively.
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
attributes object optional MERGED into the contact's attribute map — names you do not send are
left as they are, and this field cannot remove one. 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 optional Master opt-out. Defaults to 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> | null optional Exact static-List membership. 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. 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.
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.
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.
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 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 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