Content & Send API

The content API is the other half of the Audience API: templates, rendering, campaigns and sending. Together they let a script — or an agent — write a newsletter and ship it without ever opening the app.

Authentication

Same key, same rules as the Audience API: an x-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:
StatusWhen
202Send accepted, and happening in the background — nothing is finished yet
409Campaign already sent, or already sending

Endpoints at a glance

EndpointWhat it does
GET /api/v1/templatesTemplates of the organization, paginated
GET /api/v1/templates/{templateId}One template with its HTML
POST /api/v1/templatesCreates a template
PATCH /api/v1/templates/{templateId}Updates name, subject or HTML
DELETE /api/v1/templates/{templateId}Deletes a template
POST /api/v1/renderRenders HTML without sending anything
GET /api/v1/campaignsCampaigns of the organization, paginated
POST /api/v1/campaignsCreates a campaign in draft
GET /api/v1/campaigns/{campaignId}State, target, recipient count and send progress
POST /api/v1/campaigns/{campaignId}/sendTriggers 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

ParameterInRequiredDescription
searchquerynoMatches the template name, case-insensitive
pagequerynoDefaults to 1
limitquerynoDefaults to 20, capped at 100
curl -H "x-api-key: $MK_API_KEY" \
  "https://app.example.com/api/v1/templates?search=newsletter"
Each template carries 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"
A template of another organization answers 404, like an id that does not exist.

POST /api/v1/templates

Creates a template. Answers 201.
FieldInRequiredDescription
namebodyyes1 to 100 characters
defaultSubjectbodyyes1 to 200 characters, used by campaigns that do not override it
contentbodyyesThe 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"
The response adds one field to the template: 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.
FieldInRequiredDescription
namebodyno1 to 100 characters
defaultSubjectbodyno1 to 200 characters
contentbodynoThe HTML of the email
A 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.
FieldInRequiredDescription
templateIdbodysee noteRenders a template of your organization
contentbodysee noteRenders raw HTML — wins over the template's own content
subjectbodynoDefaults to the template's defaultSubject
variablesbodynoSample 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.
The unsubscribe link used while rendering is a sample URL: a render has no recipient, so it has no real unsubscribe token. It is there to prove the link exists, not to work.

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.
FieldInRequiredDescription
namebodyyes1 to 100 characters, internal name
subjectbodyyes1 to 200 characters, the subject recipients see
templateIdbodyyesTemplate of your organization
listIdbodyyesList to send to
tagIdbodynoNarrows 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

ParameterInRequiredDescription
statusquerynodraft, scheduled, sending, sent, failed
searchquerynoMatches the campaign name, case-insensitive
pagequerynoDefaults to 1
limitquerynoDefaults to 20, capped at 100

GET /api/v1/campaigns/{campaignId}

The campaign, plus three computed fields:
FieldDescription
recipientCountHow many subscribed contacts the target reaches right now — computed live, never stored
progress{total, sent, failed, pending} of the actual sends
sendingEnabledfalse 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 stateAnswer
draft or scheduled202 — the send is requested, a scheduled campaign goes now
sending409 — a send is already running
sent409 — it already went out
failed409 — not in a sendable state
no unsubscribe link in the content400 — nothing is emitted
unknown, or another organization404
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.
Then poll GET /api/v1/campaigns/{campaignId}: status moves draftsendingsent, and progress fills up along the way. There is no webhook to wait for.

When polling never converges

A 202 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 seeWhat it meansWhat to do
draft, progress.total = 0, sendingEnabled = falseMass sending is switched off account-wide (kill switch)Nothing on your side — sending has to be re-enabled
draft, progress.total = 0, sendingEnabled = trueThe 24 h sending quota would be exceeded by this campaign's recipientCountWait 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')
Check 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}'
A 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}'
Lists and tags come from the Audience API. Keep the 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'
Zero means the target matches nobody. Sending would be a no-op — fix the target first.
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}'
Poll until 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.