Audience API

The audience API exposes contacts and tags over HTTP so a script — or an agent — can work on the same data as the app, without a browser session.

Authentication

Every request carries an API key in the x-api-key header, and the key belongs to one organization.
curl -H "x-api-key: $MK_API_KEY" \
  "https://app.example.com/api/v1/contacts?listId=$LIST_ID"
Authentication, the roles required to read and to write, the response envelope and the full list of status codes are on the API reference. Reading here needs to be a member of the organization; everything that writes — updating, tagging, unsubscribing — needs admin.

Contacts

GET /api/v1/contacts

Contacts of one list, paginated.
ParameterInRequiredDescription
listIdqueryyesList to read. Contacts are always scoped to a list.
statusquerynopending, subscribed, unsubscribed, bounced, complained
tagIdquerynoOnly contacts carrying this tag — a tag id, not a name
searchquerynoMatches email, first name or last name
pagequerynoDefaults to 1
limitquerynoDefaults to 20, capped at 100
listId is required: a read is always scoped to one list, there is no organization-wide contact listing. A request without it answers 400. Get your list ids from the app. tagId takes the id of a tag, not its name: call GET /api/v1/tags first and pick the id of the tag whose name you are after.
curl -H "x-api-key: $MK_API_KEY" \
  "https://app.example.com/api/v1/contacts?listId=$LIST_ID&status=subscribed&limit=50"
The organization is never a parameter. It comes from the key. An organizationId passed in the query is ignored — you can only ever read your own audience. A listId belonging to another organization answers 404, not an empty page.
Every contact in the response carries exactly these fields: id, email, firstName, lastName, status, listId, list, tags and its createdAt / updatedAt dates. Nothing else — in particular, the unsubscribe token of a contact is never exposed by this API.

GET /api/v1/contacts/{contactId}

One contact with its list and its tags.
curl -H "x-api-key: $MK_API_KEY" \
  "https://app.example.com/api/v1/contacts/$CONTACT_ID"

PATCH /api/v1/contacts/{contactId}

Updates the identity of a contact. Only two fields are accepted:
FieldInRequiredDescription
firstNamebodynoUp to 100 characters, null clears it
lastNamebodynoUp to 100 characters, null clears it
A field you do not send is left untouched.
curl -X PATCH -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
  -d '{"firstName":"Camille","lastName":"Dubois"}' \
  "https://app.example.com/api/v1/contacts/$CONTACT_ID"
The status is not editable here. A status sent in the body is ignored — it never changes the subscription state. Use /unsubscribe and /resubscribe, which are explicit acts.

POST /api/v1/contacts/{contactId}/unsubscribe

Unsubscribes the contact. Idempotent: calling it on an already unsubscribed contact changes nothing and still answers 200.
curl -X POST -H "x-api-key: $MK_API_KEY" \
  "https://app.example.com/api/v1/contacts/$CONTACT_ID/unsubscribe"

POST /api/v1/contacts/{contactId}/resubscribe

Resubscribes the contact — an explicit act, never a side effect of an import or an upsert. Idempotent as well. A contact in bounced state is refused with 400: a hard bounce is final and cannot be undone through the API.

Tags

GET /api/v1/tags

Tags of the organization with how many contacts carry each of them. This is also how you turn a tag name into the tagId that GET /api/v1/contacts expects.
ParameterInRequiredDescription
searchquerynoFilters by name, case-insensitive
A tag is returned with its id, name, createdAt / updatedAt dates, and contactCount on this list only. Nothing else — like contacts, the organization never appears in a response: it is the one your key belongs to.

POST /api/v1/tags

Creates a tag. Answers 201 with the created tag.
FieldInRequiredDescription
namebodyyes1 to 50 characters
curl -X POST -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
  -d '{"name":"vip"}' \
  "https://app.example.com/api/v1/tags"
A name already taken — case-insensitive, so VIP collides with vip — answers 409.

POST /api/v1/contacts/{contactId}/tags

Attaches a tag to a contact by name. The tag is created in the organization if it does not exist yet, exactly like the tags sent when upserting a contact.
FieldInRequiredDescription
namebodyyes1 to 50 characters
Idempotent: attaching the same tag twice does not duplicate anything and answers 200.
curl -X POST -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
  -d '{"name":"vip"}' \
  "https://app.example.com/api/v1/contacts/$CONTACT_ID/tags"

DELETE /api/v1/contacts/{contactId}/tags/{tagId}

Detaches a tag from a contact. Idempotent: removing a tag the contact does not carry answers 200 too.

Adding a contact

POST /api/v1/lists/{listId}/contacts

Contacts enter through the list endpoint, which upserts by email:
curl -X POST -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
  -d '{"email":"camille@example.fr","firstName":"Camille","tags":["vip"]}' \
  "https://app.example.com/api/v1/lists/$LIST_ID/contacts"
An unsubscribed contact never silently comes back. Upserting an existing contact keeps its current status: only an explicit call to /resubscribe puts it back to subscribed.

Importing a batch

POST /api/v1/lists/{listId}/contacts/bulk

Pushes up to 500 contacts in one call, with everything you know about them — including the consent proof of a migrated database. Each line is upserted by email, exactly like the single endpoint above.
FieldInRequiredDescription
contactsbodyyesArray of entries, 500 maximum
Each entry:
FieldRequiredDescription
emailyesThe upsert key, together with the list
firstNamenoUp to 100 characters
lastNamenoUp to 100 characters
tagsnoNames, created in the organization if they do not exist yet
statusnopending, subscribed, unsubscribed, bounced, complainedon creation only
optinAtnoISO date of the sign-up
optinIpnoIP of the sign-up
confirmedAtnoISO date of the double opt-in confirmation
confirmedIpnoIP of the confirmation
doubleOptInRefused: an import never emails anyone, the line is rejected
curl -X POST -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
  -d '{"contacts":[
        {"email":"camille@example.fr","firstName":"Camille","lastName":"Roux","tags":["vip"],
         "status":"subscribed","optinAt":"2024-03-12T09:14:00Z","optinIp":"81.250.14.7"},
        {"email":"theo@example.com","status":"unsubscribed"}
      ]}' \
  "https://app.example.com/api/v1/lists/$LIST_ID/contacts/bulk"
A batch is invisible to the contacts. An import is not a sign-up: nothing is sent, nothing is queued, no automation starts. No confirmation email, no sequence enrolment — not for a line declared subscribed, not for a tag added by the import. People who subscribed years ago in another tool must not receive a "Welcome, thanks for subscribing!" because you changed vendors. The status you declare is simply the one the contact is created with. For the same reason a batch entry refuses doubleOptIn: the line is rejected and named in the report, rather than accepted while no confirmation is ever sent. Sign-ups go through POST /api/v1/lists/{listId}/contacts, one contact at a time — that endpoint is the one that emails and enrols.
Consent fields are written, never blanked. A field you leave out keeps whatever is already stored — this is the one piece of a migrated database that cannot be reconstructed afterwards. And an existing contact keeps its status: declaring subscribed on someone who unsubscribed changes nothing, the report still counts the line as updated.

The report

The answer is a 200 whose data is the report of the batch:
{
  "success": true,
  "data": {
    "total": 500,
    "created": 468,
    "updated": 31,
    "rejected": 1,
    "errors": [{"index": 342, "email": "pas-un-email", "reason": "email: Invalid email address"}]
  }
}
FieldMeaning
totalLines submitted — always created + updated + rejected
createdContacts that did not exist in the list
updatedContacts already there, completed without ever changing their status
rejectedLines refused, detailed one by one in errors
errors{index, email, reason}index is the position in the batch you sent
One bad line rejects that line only. The 499 others are written. There is no global transaction: a single comma cannot cost you the whole batch.

The agent loop

index is what makes the report actionable: it points at the line in the array you sent, so a failure is replayed as itself, not as a full resend.
  1. Send a chunk of 500.
  2. Read errors.
  3. Rebuild a batch from the failing indexes only — errors.map(e => batch[e.index]) — fix them, send again.
  4. Repeat until rejected is 0, then move to the next chunk.
const send = async (batch) => {
  const response = await fetch(`${base}/api/v1/lists/${listId}/contacts/bulk`, {
    method: 'POST',
    headers: {'x-api-key': key, 'content-type': 'application/json'},
    body: JSON.stringify({contacts: batch}),
  })
  return (await response.json()).data
}

for (let offset = 0; offset < all.length; offset += 500) {
  const batch = all.slice(offset, offset + 500)
  const report = await send(batch)
  const failed = report.errors.map((error) => ({...batch[error.index], ...fix(error)}))
  if (failed.length > 0) await send(failed)
}
Re-sending a line that succeeded is harmless — the endpoint upserts — but the point is that you do not have to: 47 rejected tells you which 47.
StatusWhen
200Batch processed — read the report, some lines may have been rejected
400The body itself is malformed (contacts missing or not an array)
403The key's owner is a member: importing needs admin
404Unknown list, or a list of another organization
413More than 500 entries — nothing was written, nothing was truncated
Over the limit the call is refused whole, with the limit in the message: an agent that believes it pushed 800 contacts must never discover later that only 500 went in. Templates sent to that audience use merge tags — see Merge Tags Cheat Sheet. Writing those templates, checking their rendering and sending a campaign to this audience is the other half of the API — see Content & Send API.