Async bulk contact import

Search Documentation

Search across all developer documentation

contacts

Async bulk contact import

POST /v1/contacts/bulk
Auth required api.contacts.manage

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.

202 Batch accepted for async processing application/json
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}.

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 Either the credential lacks the required scope (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
404 Resource not found 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