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/templates/{templateId}/test-send | Sends one test email of the template, writes nothing |
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 |
GET /api/v1/campaigns/{campaignId}/stats | Delivery, 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}/send | Triggers the send, now or at a chosen time — answers 202 |
POST /api/v1/campaigns/{campaignId}/test-send | Sends 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
| 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://agentsmail.io/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://agentsmail.io/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://agentsmail.io/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://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.
| Field | In | Required | Description |
|---|---|---|---|
email | body | yes | Where the test goes |
subject | body | no | Overrides the template's defaultSubject for this test only |
content | body | no | Overrides 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 send | In a test |
|---|---|
| Open tracking pixel | Absent — the pixel is addressed by a send row |
| Links rewritten for clicks | Not rewritten — click tracking is addressed by a send row |
| Merge tags per recipient | Preview sample data, the same the preview panel shows |
| Unsubscribe link | A preview link that unsubscribes nobody |
| Condition | Answer |
|---|---|
| everything allowed | 200 — the test was sent |
missing or malformed email | 400 — nothing was sent |
| no unsubscribe link in the content | 400 — same check as /render, nothing sent |
| mass sending switched off account-wide | 409 — nothing was sent |
| organization not allowed to send | 409 — nothing was sent |
| address on the organization's suppression list | 409 — nothing was sent |
| over the test cap | 409 — 10 tests per hour and per user |
| unknown, or another organization | 404 |
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.
| 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://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.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://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
| 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://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.
| Field | Description |
|---|---|
sent | Emails actually dispatched |
delivered | sent minus bounced |
bounced | Sends rejected by the receiving side |
uniqueOpens / openRate | Contacts who opened at least once, and their share of delivered |
uniqueClicks / clickRate | Contacts who clicked at least once, and their share of delivered |
unsubscribed / unsubscribeRate | Unsubscribes attributed to this campaign, and their share of delivered |
clicksByLink | Per URL: {url, uniqueClicks, totalClicks} |
clicksOverTime | Per 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"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.
| Field | in | required | notes |
|---|---|---|---|
content | body | no | The HTML of the email |
subject | body | no | 1 to 200 characters |
name | body | no | 1 to 100 characters |
listId | body | no | Retargets the campaign; must belong to the org |
tagId | body | no | Narrows the target to a tag, or null to clear |
templateId | body | no | Re-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>" }'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
| Field | In | Required | Description |
|---|---|---|---|
scheduledAt | body | no | ISO 8601 instant with an explicit offset. Omitted, the campaign goes now |
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 state | POST /send | with scheduledAt |
|---|---|---|
draft | 202 — the send is requested | 202 — the campaign is scheduled |
scheduled | 202 — it goes now | 400 — only a draft can be scheduled |
sending | 409 — a send is already running | 409 |
sent | 409 — it already went out | 409 |
failed | 409 — not in a sendable state | 409 |
| no unsubscribe link in the content | 400 — nothing is emitted | 400 — nothing is scheduled |
| unknown, or another organization | 404 | 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.
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.| In a real send | In a test |
|---|---|
| Open tracking pixel | Absent — the pixel is addressed by a send row |
| Links rewritten for clicks | Not rewritten — click tracking is addressed by a send row |
| Merge tags per recipient | Preview sample data, the same the preview panel shows |
| Unsubscribe link | A preview link that unsubscribes nobody |
List-Unsubscribe header.
| Condition | Answer |
|---|---|
draft or scheduled, everything allowed | 200 — the test was sent |
missing or malformed email | 400 — nothing was sent |
| no unsubscribe link in the content | 400 — same check as /render, nothing sent |
| mass sending switched off account-wide | 409 — nothing was sent |
| organization not allowed to send | 409 — nothing was sent |
| address on the organization's suppression list | 409 — nothing was sent |
campaign sending, sent or failed | 409 — a test is for a campaign in preparation |
| more than 10 tests in the last hour, same user | 409 — the cap is shared with the UI |
| unknown campaign, or another organization | 404 |
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
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://agentsmail.io/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://agentsmail.io/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
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}'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'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}'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.