POSTCreate a campaign/api/v1/campaignsGETList campaigns/api/v1/campaignsGETGet a campaign/api/v1/campaigns/{campaignId}PATCHEdit a draft campaign/api/v1/campaigns/{campaignId}POSTSend or schedule/api/v1/campaigns/{campaignId}/sendPOSTTest a campaign/api/v1/campaigns/{campaignId}/test-sendGETRead campaign stats/api/v1/campaigns/{campaignId}/stats
What a campaign is, over HTTP
A campaign is a template, a subject and a target: one list, optionally narrowed to one tag. It also picks who signs it — one of the addresses in your sender address book, or, by default, the organization's default address.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.
bash
curl -X POST -H "x-api-key: $AGENTMAIL_API_KEY" -H "content-type: application/json" \
-d '{"scheduledAt": "2026-08-12T07:00:00Z"}' \
"https://www.agentsmail.io/api/v1/campaigns/$CAMPAIGN_ID/send"json
{"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.
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.A campaign from nothing, end to end
1
Write the template
bash
TEMPLATE_ID=$(curl -s -X POST -H "x-api-key: $AGENTMAIL_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://www.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
bash
curl -s -X POST -H "x-api-key: $AGENTMAIL_API_KEY" -H "content-type: application/json" \
-d '{"templateId":"'$TEMPLATE_ID'","variables":{"firstName":"Camille"}}' \
"https://www.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
bash
LIST_ID=$(curl -s -H "x-api-key: $AGENTMAIL_API_KEY" \
"https://www.agentsmail.io/api/v1/lists" | jq -r '.data[0].id')
curl -s -H "x-api-key: $AGENTMAIL_API_KEY" \
"https://www.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
bash
CAMPAIGN_ID=$(curl -s -X POST -H "x-api-key: $AGENTMAIL_API_KEY" -H "content-type: application/json" \
-d '{
"name": "August newsletter",
"subject": "What shipped in August",
"templateId": "'$TEMPLATE_ID'",
"listId": "'$LIST_ID'"
}' \
"https://www.agentsmail.io/api/v1/campaigns" | jq -r '.data.id')5
Count the recipients before committing
bash
curl -s -H "x-api-key: $AGENTMAIL_API_KEY" \
"https://www.agentsmail.io/api/v1/campaigns/$CAMPAIGN_ID" | jq '.data.recipientCount'6
Send
bash
curl -s -X POST -H "x-api-key: $AGENTMAIL_API_KEY" \
"https://www.agentsmail.io/api/v1/campaigns/$CAMPAIGN_ID/send"202 means the send was accepted, not finished.7
Follow it
bash
curl -s -H "x-api-key: $AGENTMAIL_API_KEY" \
"https://www.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.