Authentication
Every request carries an API key in thex-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"member of the
organization; everything that writes — updating, tagging, unsubscribing — needs admin.
Contacts
GET /api/v1/contacts
Contacts of one list, paginated.
| Parameter | In | Required | Description |
|---|---|---|---|
listId | query | yes | List to read. Contacts are always scoped to a list. |
status | query | no | pending, subscribed, unsubscribed, bounced, complained |
tagId | query | no | Only contacts carrying this tag — a tag id, not a name |
search | query | no | Matches email, first name or last name |
page | query | no | Defaults to 1 |
limit | query | no | Defaults 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.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:
| Field | In | Required | Description |
|---|---|---|---|
firstName | body | no | Up to 100 characters, null clears it |
lastName | body | no | Up to 100 characters, null clears it |
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.
| Parameter | In | Required | Description |
|---|---|---|---|
search | query | no | Filters by name, case-insensitive |
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.
| Field | In | Required | Description |
|---|---|---|---|
name | body | yes | 1 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"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.
| Field | In | Required | Description |
|---|---|---|---|
name | body | yes | 1 to 50 characters |
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.
| Field | In | Required | Description |
|---|---|---|---|
contacts | body | yes | Array of entries, 500 maximum |
| Field | Required | Description |
|---|---|---|
email | yes | The upsert key, together with the list |
firstName | no | Up to 100 characters |
lastName | no | Up to 100 characters |
tags | no | Names, created in the organization if they do not exist yet |
status | no | pending, subscribed, unsubscribed, bounced, complained — on creation only |
optinAt | no | ISO date of the sign-up |
optinIp | no | IP of the sign-up |
confirmedAt | no | ISO date of the double opt-in confirmation |
confirmedIp | no | IP of the confirmation |
doubleOptIn | — | Refused: 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"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 a200 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"}]
}
}| Field | Meaning |
|---|---|
total | Lines submitted — always created + updated + rejected |
created | Contacts that did not exist in the list |
updated | Contacts already there, completed without ever changing their status |
rejected | Lines refused, detailed one by one in errors |
errors | {index, email, reason} — index is the position in the batch you sent |
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.
- Send a chunk of 500.
- Read
errors. - Rebuild a batch from the failing indexes only —
errors.map(e => batch[e.index])— fix them, send again. - Repeat until
rejectedis0, 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)
}47 rejected tells you which 47.
| Status | When |
|---|---|
200 | Batch processed — read the report, some lines may have been rejected |
400 | The body itself is malformed (contacts missing or not an array) |
403 | The key's owner is a member: importing needs admin |
404 | Unknown list, or a list of another organization |
413 | More than 500 entries — nothing was written, nothing was truncated |