Async bulk contact import
Submits a batch of up to 5 000 contacts for asynchronous upsert.
The endpoint returns immediately with a job id and 202 Accepted;
poll GET /v1/contacts/bulk/{job_id} until status is completed
or failed.
Each row is processed independently — a single row's validation
failure (unknown attribute, malformed email, blank external_id,
unknown List handle) marks that row failed while the rest of the batch
continues. The overall job never returns failed due to per-row errors.
A row's attributes MERGE into the contact's map, with the same
contract as PATCH /v1/contacts/{email}: a name you send is written, a
name you do not send keeps its current value, and a null value
REMOVES the attribute from the contact.
Read the failed rows from GET /v1/contacts/bulk/{job_id}: each carries
its index in the submitted array and a reason. A row that fails
validation is never written — it reaches neither the contact list nor SES.
422 is reserved for faults in the request as a whole — an unparseable
body, an empty contacts array, or more than 5 000 items. A malformed
row does not produce a 422, and never costs the rest of the batch.
Supports the Idempotency-Key header: a duplicate key within 24 h
replays the original 202.
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
strict boolean optional Opt the whole batch out of attribute auto-registration — bulk's
equivalent of the strict=true query parameter on the
single-contact writes. A body field rather than a query parameter
because everything else about this batch is in the body, so a caller
reviewing a stored request payload can see whether it could create
definitions.
With true, a row naming an unregistered attribute fails with an
attribute errors: … reason on
GET /v1/contacts/bulk/{job_id} and nothing is registered. Default
false registers the name as a string attribute and lists every
name created in the 202 response's attributes_registered.
Default: false
contacts array<object> required The contacts to import. Capped at 5000 per request.
Constraints: 1–5000 items
Each entry in contacts:
email string<email> required external_id string | null optional Your own identifier for this contact. When present the row resolves
exactly as PUT /v1/contacts/by-external-id/{external_id} does —
it creates the contact with that id, adopts an existing contact whose
external_id is unset, or updates the contact that already carries
it. Omit it for the plain email-keyed upsert, which leaves
external_id untouched.
If the email already belongs to a contact carrying a different
external id, only that row fails (outcome: "failed", with a
reason naming the conflicting id) — the rest of the batch still
applies. Use rebind_external_id to re-key instead of failing.
rebind_external_id boolean optional When the row's email already belongs to a contact carrying a
different external id, the import normally fails that row rather
than steal the contact from its current identity. Set this to true
to instead rebind the email onto this row's external_id — an
authoritative "I own this identity, re-key it" write that heals a
stale external id in place. Ignored when external_id is absent.
Leave false (the default) unless you are the sole owner of the
contact.
Default: false
attributes object optional Custom attribute map, MERGED into the contact's attributes. A name
you send is written, a name you do not send keeps its current
value, and a null value REMOVES the attribute — the same contract
as PATCH /v1/contacts/{email}. Validated per-row; a failure marks
only that row failed.
An attribute name your org has never used is REGISTERED as a
string attribute, and every name this batch created is listed in
the 202 response's attributes_registered. Set the top-level
strict field to opt out: an unknown key then fails only its own
row and nothing is registered.
unsubscribe_all boolean | null optional Master opt-out. Omitted-means-untouched — a null/absent value never
changes the contact's current opt-out state, so re-importing an
existing audience cannot resurrect its unsubscribes. Send false
explicitly to re-subscribe a contact, and true to opt them out.
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 object optional Inline static-List membership directive carried on POST and PATCH
contact writes, and on PUT /v1/contacts/by-external-id/{external_id}
as the object form of lists. add/remove apply a delta; set, when present,
replaces membership declaratively (an empty set clears all
memberships).
Every 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, so one identifier works across the whole API. Use the key:
it is immutable, readable in a config file, and what GET /v1/lists
shows beside each List. An entry that names no static List in your org
is a 422 validation_failed raised before anything is written; a
Segment's key is not accepted here.
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.
job_id string<uuid> required status string required Job lifecycle state — pending, running, completed, or failed.
total integer required Number of contacts accepted for processing.
attributes_registered array<string> required Attribute names this batch added to your org's attribute registry,
across every row. Always present; [] when the batch registered
nothing, and always [] when the body set "strict": true.
It is on this response rather than on each per-row outcome because
attribute coercion happens when the batch is ACCEPTED, not when the
worker reaches the row — so a name registers once for the whole
batch and a per-row copy would repeat one list up to 5,000 times. A
row whose attributes could not be used is failed individually, with
its reason on GET /v1/contacts/bulk/{job_id}.
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 code: invalid_scope), or it is bound to the test environment and this operation is irreversible (code: test_environment_forbidden). Branch on code: the first is fixed by granting the scope, the second only by using a live credential. See the "Live and test credentials" section of the API description. 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