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/templates/{templateId}/test-sendSends one test email of the template, writes nothing
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
GET /api/v1/campaigns/{campaignId}/statsDelivery, opens, clicks and unsubscribes of a campaign
PATCH /api/v1/campaigns/{campaignId}Edits a draft — HTML, subject, name or target
POST /api/v1/campaigns/{campaignId}/sendTriggers the send, now or at a chosen time — answers 202
POST /api/v1/campaigns/{campaignId}/test-sendSends one test email, writes nothing

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://agentsmail.io/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://agentsmail.io/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://agentsmail.io/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://agentsmail.io/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.

POST /api/v1/templates/{templateId}/test-send

Sends one email — the template as it stands — to an address you choose, so a human can check rendering in a real inbox before any campaign is built on it. It is the API side of the "Send a test" button on the template screen.
FieldInRequiredDescription
emailbodyyesWhere the test goes
subjectbodynoOverrides the template's defaultSubject for this test only
contentbodynoOverrides the template's HTML for this test only
curl -X POST -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
  "https://agentsmail.io/api/v1/templates/$TEMPLATE_ID/test-send" \
  -d '{ "email": "you@example.com" }'
{"success": true, "data": {"templateId": "…", "email": "you@example.com", "status": "test_sent"}}
A test spends a real send. It goes out through the same path as a campaign — same SES identity, same configuration set, same subject and HTML, no [TEST] prefix — and counts against the 24 h sending quota. Unlike POST /api/v1/render, this endpoint is not free to call in a loop.
subject and content override what is rendered, never who you are. They exist so the editor can test the draft it is showing, before saving. The templateId still decides everything that matters: which organization is allowed to send, which suppression list is checked, and which SES identity signs the message. Passing HTML does not turn this into a free-form send endpoint. What a test writes: nothing. No send row is created, so no statistic moves anywhere. Two consequences follow, the same ones as for a campaign test:
In a real sendIn a test
Open tracking pixelAbsent — the pixel is addressed by a send row
Links rewritten for clicksNot rewritten — click tracking is addressed by a send row
Merge tags per recipientPreview sample data, the same the preview panel shows
Unsubscribe linkA preview link that unsubscribes nobody
ConditionAnswer
everything allowed200 — the test was sent
missing or malformed email400 — nothing was sent
no unsubscribe link in the content400 — same check as /render, nothing sent
mass sending switched off account-wide409 — nothing was sent
organization not allowed to send409 — nothing was sent
address on the organization's suppression list409 — nothing was sent
over the test cap409 — 10 tests per hour and per user
unknown, or another organization404
The test cap is shared with campaign tests. Ten tests per hour and per user, campaigns and templates combined — spending it on /campaigns/{campaignId}/test-send blocks this endpoint too, and the other way round. The cap lives in the service, so the screen and the API draw on the same budget: neither is a way around the other.

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://agentsmail.io/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://agentsmail.io/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://agentsmail.io/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.

GET /api/v1/campaigns/{campaignId}/stats

How the campaign performed — the same figures the Stats screen shows.
FieldDescription
sentEmails actually dispatched
deliveredsent minus bounced
bouncedSends rejected by the receiving side
uniqueOpens / openRateContacts who opened at least once, and their share of delivered
uniqueClicks / clickRateContacts who clicked at least once, and their share of delivered
unsubscribed / unsubscribeRateUnsubscribes attributed to this campaign, and their share of delivered
clicksByLinkPer URL: {url, uniqueClicks, totalClicks}
clicksOverTimePer day: {date, clicks}, date as YYYY-MM-DD
curl -H "x-api-key: $MK_API_KEY" \
  "https://agentsmail.io/api/v1/campaigns/$CAMPAIGN_ID/stats"
Rates are shares of delivered, not of sent — an address that bounced never had the chance to open. While nothing is delivered yet the denominator is zero and every rate reads 0, never NaN: poll progress on the campaign itself to know whether the send is over before reading anything into them. A campaign of another organization answers 404, like an id that does not exist.

Sending

PATCH /api/v1/campaigns/{campaignId}

Edits a draft campaign. A campaign is created with a copy of its template's HTML, then lives on its own: this endpoint edits that copy — the HTML actually sent — without touching the template. A campaign that has already been sent or is sending is frozen and returns 400. Every field is optional; only those you send are changed.
Fieldinrequirednotes
contentbodynoThe HTML of the email
subjectbodyno1 to 200 characters
namebodyno1 to 100 characters
listIdbodynoRetargets the campaign; must belong to the org
tagIdbodynoNarrows the target to a tag, or null to clear
templateIdbodynoRe-links the source template; must belong to the org
curl -X PATCH -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
  https://agentsmail.io/api/v1/campaigns/$CAMPAIGN_ID \
  -d '{ "content": "<html><body><p>Hi {{firstName}}</p><a href=\"{{unsubscribeUrl}}\">Unsubscribe</a></body></html>" }'
Mailchimp merge tags in the HTML are converted as on template creation; any unsupported tag is returned in unsupportedTags.

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. Called with no body, it sends now. Called with a scheduledAt, it books the send for that instant — same endpoint, same key, same permissions.
curl -X POST -H "x-api-key: $MK_API_KEY" \
  "https://agentsmail.io/api/v1/campaigns/$CAMPAIGN_ID/send"
{"success": true, "data": {"campaignId": "…", "status": "send_requested"}}

Scheduling: one optional field, same endpoint

FieldInRequiredDescription
scheduledAtbodynoISO 8601 instant with an explicit offset. Omitted, the campaign goes now
No body at all, an empty body, or a body without scheduledAt all mean the same thing: send now. Callers written before this field have nothing to change.
curl -X POST -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
  -d '{"scheduledAt": "2026-08-12T07:00:00Z"}' \
  "https://agentsmail.io/api/v1/campaigns/$CAMPAIGN_ID/send"
{"success": true, "data": {"campaignId": "…", "status": "scheduled"}}
status tells the two apart: send_requested for a send starting now, scheduled for one booked for later. The campaign moves to scheduled, and GET /api/v1/campaigns/{campaignId} gives back the instant that was actually stored — worth reading once, since the schedule is kept to the minute and the seconds you pass are dropped.
A date with no timezone is refused, never read as UTC. 2026-08-12T09:00:00 answers 400: the API does not guess an offset you did not write, because guessing it wrong sends an hour or two off with nothing in the request to show for it. Write 2026-08-12T07:00:00Z, or 2026-08-12T09:00:00+02:00 — both name the same instant. An instant in the past, or less than a couple of minutes away, is refused the same way.
Scheduling is for a draft, and there is no API way back. A campaign that is already scheduled answers 400 — it is not rescheduled in place. Cancelling a schedule is done from the app, on the campaign screen; no endpoint exposes it. Book the time you mean.
Campaign statePOST /sendwith scheduledAt
draft202 — the send is requested202 — the campaign is scheduled
scheduled202 — it goes now400 — only a draft can be scheduled
sending409 — a send is already running409
sent409 — it already went out409
failed409 — not in a sendable state409
no unsubscribe link in the content400 — nothing is emitted400 — nothing is scheduled
unknown, or another organization404404
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.

POST /api/v1/campaigns/{campaignId}/test-send

Sends one email — the campaign exactly as it is written — to an address you choose, so a human can check inbox placement and rendering before the real send. It is the API side of the "Send a test" button.
curl -X POST -H "x-api-key: $MK_API_KEY" -H "content-type: application/json" \
  "https://agentsmail.io/api/v1/campaigns/$CAMPAIGN_ID/test-send" \
  -d '{ "email": "you@example.com" }'
{"success": true, "data": {"campaignId": "…", "email": "you@example.com", "status": "test_sent"}}
A test spends a real send. It goes out through the same path as a campaign — same SES identity, same configuration set, same subject and HTML, no [TEST] prefix — and counts against the 24 h sending quota. Unlike POST /api/v1/render, this endpoint is not free to call in a loop.
What a test writes: nothing. No send row is created, so the campaign's counters, its status and its statistics are untouched. Two consequences follow from that, and they are the reason the endpoint is honest about its limits:
In a real sendIn a test
Open tracking pixelAbsent — the pixel is addressed by a send row
Links rewritten for clicksNot rewritten — click tracking is addressed by a send row
Merge tags per recipientPreview sample data, the same the preview panel shows
Unsubscribe linkA preview link that unsubscribes nobody
That last row matters: the unsubscribe page unsubscribes on load. A test carrying a real contact's token would unsubscribe that contact on the first click — including a spam filter's pre-click. So a test always carries the inert preview link, in the body and in the List-Unsubscribe header.
ConditionAnswer
draft or scheduled, everything allowed200 — the test was sent
missing or malformed email400 — nothing was sent
no unsubscribe link in the content400 — same check as /render, nothing sent
mass sending switched off account-wide409 — nothing was sent
organization not allowed to send409 — nothing was sent
address on the organization's suppression list409 — nothing was sent
campaign sending, sent or failed409 — a test is for a campaign in preparation
more than 10 tests in the last hour, same user409 — the cap is shared with the UI
unknown campaign, or another organization404
The cap is carried by the user behind the key, not by the endpoint. Ten tests per hour, shared with the "Send a test" button in the app — calling the API is not a way around the interface's budget.

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://agentsmail.io/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://agentsmail.io/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

LIST_ID=$(curl -s -H "x-api-key: $MK_API_KEY" \
  "https://agentsmail.io/api/v1/lists" | jq -r '.data[0].id')

curl -s -H "x-api-key: $MK_API_KEY" \
  "https://agentsmail.io/api/v1/tags" | jq '.data[] | {id, name, contactCount}'
List ids come from GET /api/v1/lists — see Discovery. Tags come from the Audience API; keep the id of the one 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://agentsmail.io/api/v1/campaigns" | jq -r '.data.id')
5

Count the recipients before committing

curl -s -H "x-api-key: $MK_API_KEY" \
  "https://agentsmail.io/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://agentsmail.io/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://agentsmail.io/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.