Authentication
Same key, same rules as the Audience API: anx-api-key header, one organization per
key, and a 404 on anything belonging to another organization. Authentication, roles, envelope and
status codes are on the API reference.
Reading templates and campaigns needs to be a member of the organization. Writing one — and
sending — needs admin. POST /api/v1/render is the exception: it changes nothing, so a member
can call it.
Two status codes matter here more than anywhere else:
| Status | When |
|---|---|
202 | Send accepted, and happening in the background — nothing is finished yet |
409 | Campaign already sent, or already sending |
Endpoints at a glance
| Endpoint | What it does |
|---|---|
GET /api/v1/templates | Templates of the organization, paginated |
GET /api/v1/templates/{templateId} | One template with its HTML |
POST /api/v1/templates | Creates a template |
PATCH /api/v1/templates/{templateId} | Updates name, subject or HTML |
DELETE /api/v1/templates/{templateId} | Deletes a template |
POST /api/v1/render | Renders HTML without sending anything |
GET /api/v1/campaigns | Campaigns of the organization, paginated |
POST /api/v1/campaigns | Creates a campaign in draft |
GET /api/v1/campaigns/{campaignId} | State, target, recipient count and send progress |
POST /api/v1/campaigns/{campaignId}/send | Triggers the send — answers 202, never waits |
Templates
A template is an HTML file. There is no layout around it: whatever you send is what recipients receive, which also means the unsubscribe link has to be in your own markup — see Merge Tags Cheat Sheet.GET /api/v1/templates
| Parameter | In | Required | Description |
|---|---|---|---|
search | query | no | Matches the template name, case-insensitive |
page | query | no | Defaults to 1 |
limit | query | no | Defaults to 20, capped at 100 |
curl -H "x-api-key: $MK_API_KEY" \
"https://app.example.com/api/v1/templates?search=newsletter"id, name, defaultSubject, content and its createdAt / updatedAt
dates. Nothing else.
GET /api/v1/templates/{templateId}
One template, with its full HTML in content.
curl -H "x-api-key: $MK_API_KEY" \
"https://app.example.com/api/v1/templates/$TEMPLATE_ID"404, like an id that does not exist.
POST /api/v1/templates
Creates a template. Answers 201.
| Field | In | Required | Description |
|---|---|---|---|
name | body | yes | 1 to 100 characters |
defaultSubject | body | yes | 1 to 200 characters, used by campaigns that do not override it |
content | body | yes | The HTML of the email |
curl -X POST -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
-d '{
"name": "August newsletter",
"defaultSubject": "What shipped in August",
"content": "<html><body><p>Hi {{firstName}}</p><a href=\"{{unsubscribeUrl}}\">Unsubscribe</a></body></html>"
}' \
"https://app.example.com/api/v1/templates"unsupportedTags.
{
"success": true,
"data": {
"id": "…",
"name": "August newsletter",
"defaultSubject": "What shipped in August",
"content": "…",
"unsupportedTags": ["*|DATE:d F Y|*"],
"createdAt": "…",
"updatedAt": "…"
}
}Mailchimp merge tags are converted on the way in.
*|UNSUB|* becomes {{unsubscribeUrl}},
*|FNAME|* becomes {{firstName}}, *|CURRENT_YEAR|* becomes {{currentYear}}, and so on. A tag
with no equivalent is left exactly where it
is and listed in unsupportedTags — you find out while you write, not when the email lands in an
inbox with a raw *|DATE:d F Y|* in it.PATCH /api/v1/templates/{templateId}
Updates a template. Only the fields you send are touched; the others are left alone.
| Field | In | Required | Description |
|---|---|---|---|
name | body | no | 1 to 100 characters |
defaultSubject | body | no | 1 to 200 characters |
content | body | no | The HTML of the email |
content you send goes through the same merge tag conversion, and the response carries
unsupportedTags again — empty when you did not touch the content.
DELETE /api/v1/templates/{templateId}
curl -X DELETE -H "x-api-key: $MK_API_KEY" \
"https://app.example.com/api/v1/templates/$TEMPLATE_ID"A template used by a campaign or a sequence cannot be deleted. The answer is a
409 that names
what holds it — Template utilisé, suppression impossible — campagnes : « August newsletter ».
Retrying will not help: detach the campaign or the sequence first, or keep the template. What was
sent stays traceable.Rendering — the endpoint to call the most
POST /api/v1/render
Renders an email with sample data and returns the result. It sends nothing, stores nothing and
changes nothing — it is the only endpoint here with no side effect, so call it as often as you
like, before and after every edit.
| Field | In | Required | Description |
|---|---|---|---|
templateId | body | see note | Renders a template of your organization |
content | body | see note | Renders raw HTML — wins over the template's own content |
subject | body | no | Defaults to the template's defaultSubject |
variables | body | no | Sample values: firstName, lastName, email |
unsubscribeUrl and currentYear are not sample values: the server computes them and
overrides anything you send for them. The year comes from the Europe/Paris clock at render time.
One of templateId or content is required — sending neither answers 400. Sending both renders
your content with the template's subject, which is how you try an edit before saving it.
curl -X POST -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
-d '{"templateId":"'$TEMPLATE_ID'","variables":{"firstName":"Camille"}}' \
"https://app.example.com/api/v1/render"{
"success": true,
"data": {
"html": "<html>…<p>Hi Camille</p>…</html>",
"text": "Hi Camille …",
"subject": "What shipped in August",
"unknownVariables": ["company"]
}
}unknownVariables lists the {{…}} placeholders that are not part of the vocabulary. They resolve
to an empty string at send time — nothing breaks, but nothing shows either. Fix them here.
HTML without an unsubscribe link is refused, here and at send time. The answer is a
400
naming the problem: add {{unsubscribeUrl}} inside a link in your HTML. Nothing is ever appended
to your markup on your behalf, so a template that renders is a template that can be sent.Campaigns
A campaign is a template, a subject and a target: one list, optionally narrowed to one tag.POST /api/v1/campaigns
Creates a campaign in draft. Answers 201.
| Field | In | Required | Description |
|---|---|---|---|
name | body | yes | 1 to 100 characters, internal name |
subject | body | yes | 1 to 200 characters, the subject recipients see |
templateId | body | yes | Template of your organization |
listId | body | yes | List to send to |
tagId | body | no | Narrows the list to the contacts carrying this tag |
curl -X POST -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
-d '{
"name": "August newsletter",
"subject": "What shipped in August",
"templateId": "'$TEMPLATE_ID'",
"listId": "'$LIST_ID'"
}' \
"https://app.example.com/api/v1/campaigns"The content is copied from the template, it is not part of the body. Edit the template, then
create the campaign — a campaign never drifts from what you rendered.
tagId takes an id, not a
name: resolve it with GET /api/v1/tags.GET /api/v1/campaigns
| Parameter | In | Required | Description |
|---|---|---|---|
status | query | no | draft, scheduled, sending, sent, failed |
search | query | no | Matches the campaign name, case-insensitive |
page | query | no | Defaults to 1 |
limit | query | no | Defaults to 20, capped at 100 |
GET /api/v1/campaigns/{campaignId}
The campaign, plus three computed fields:
| Field | Description |
|---|---|
recipientCount | How many subscribed contacts the target reaches right now — computed live, never stored |
progress | {total, sent, failed, pending} of the actual sends |
sendingEnabled | false when mass sending is switched off — nothing will leave, whatever you do |
curl -H "x-api-key: $MK_API_KEY" \
"https://app.example.com/api/v1/campaigns/$CAMPAIGN_ID"recipientCount counts subscribers only: unsubscribed, bounced and complained contacts are already
out. It is the honest answer to "how many people will get this", and it is also how you catch a
target that matches nobody before sending.
Sending
POST /api/v1/campaigns/{campaignId}/send
Triggers the send and answers 202 immediately. The send itself runs in the background, paced
against the sending quota — the request never waits for it.
curl -X POST -H "x-api-key: $MK_API_KEY" \
"https://app.example.com/api/v1/campaigns/$CAMPAIGN_ID/send"{"success": true, "data": {"campaignId": "…", "status": "send_requested"}}| Campaign state | Answer |
|---|---|
draft or scheduled | 202 — the send is requested, a scheduled campaign goes now |
sending | 409 — a send is already running |
sent | 409 — it already went out |
failed | 409 — not in a sendable state |
| no unsubscribe link in the content | 400 — nothing is emitted |
| unknown, or another organization | 404 |
The content is rendered before anything is emitted. It is the exact same check as
POST /api/v1/render: a campaign whose HTML carries no {{unsubscribeUrl}} answers 400 and no
send is created. Without it, every single send would fail later for a reason the API never shows
you. Fix the template, recreate the campaign, then send.Calling send twice never sends twice. A campaign that already went out answers
409 and no
second send is started — retrying after a timeout is safe. If you want to send the same content
again, create a new campaign.GET /api/v1/campaigns/{campaignId}: status moves draft → sending → sent, and
progress fills up along the way. There is no webhook to wait for.
When polling never converges
A202 means the request was accepted, not that the send will happen. Two conditions stop it after
the fact, and neither changes the campaign: it stays draft, progress stays at zero, and no
error is ever reported to you.
| What you see | What it means | What to do |
|---|---|---|
draft, progress.total = 0, sendingEnabled = false | Mass sending is switched off account-wide (kill switch) | Nothing on your side — sending has to be re-enabled |
draft, progress.total = 0, sendingEnabled = true | The 24 h sending quota would be exceeded by this campaign's recipientCount | Wait for the quota window to roll, then call /send again |
Stop polling if the campaign is still
draft with no progress. It will never move to sent on
its own. Read sendingEnabled: false means the kill switch, true means the daily quota — in
both cases the campaign is intact and calling /send again later is safe, it is still a draft.The full journey
1
Write the template
TEMPLATE_ID=$(curl -s -X POST -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
-d '{
"name": "August newsletter",
"defaultSubject": "What shipped in August",
"content": "<html><body><p>Hi {{firstName}}</p><p>Here is what we shipped.</p><a href=\"{{unsubscribeUrl}}\">Unsubscribe</a></body></html>"
}' \
"https://app.example.com/api/v1/templates" | jq -r '.data.id')unsupportedTags in the response: anything listed there is still raw in your HTML.2
Render it, and read what you get
curl -s -X POST -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
-d '{"templateId":"'$TEMPLATE_ID'","variables":{"firstName":"Camille"}}' \
"https://app.example.com/api/v1/render" | jq '{subject, unknownVariables: .data.unknownVariables}'400 here means the HTML has no unsubscribe link. Fix the template with PATCH and render
again — this loop costs nothing.3
Pick the target
curl -s -H "x-api-key: $MK_API_KEY" \
"https://app.example.com/api/v1/tags" | jq '.data[] | {id, name, contactCount}'id of the tag you want,
if any.4
Create the campaign
CAMPAIGN_ID=$(curl -s -X POST -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
-d '{
"name": "August newsletter",
"subject": "What shipped in August",
"templateId": "'$TEMPLATE_ID'",
"listId": "'$LIST_ID'"
}' \
"https://app.example.com/api/v1/campaigns" | jq -r '.data.id')5
Count the recipients before committing
curl -s -H "x-api-key: $MK_API_KEY" \
"https://app.example.com/api/v1/campaigns/$CAMPAIGN_ID" | jq '.data.recipientCount'6
Send
curl -s -X POST -H "x-api-key: $MK_API_KEY" \
"https://app.example.com/api/v1/campaigns/$CAMPAIGN_ID/send"202 means the send was accepted, not finished.7
Follow it
curl -s -H "x-api-key: $MK_API_KEY" \
"https://app.example.com/api/v1/campaigns/$CAMPAIGN_ID" | jq '{status: .data.status, progress: .data.progress}'status is sent. Do not call /send again while waiting — it answers 409. If
the campaign is still draft with progress.total at zero, stop polling and read When polling
never converges above: sending is switched off, or the daily quota is full.