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.
json
{"success": true, "data": {"campaignId": "…", "status": "send_requested"}}Scheduling: one optional field, same endpoint
No body at all, an empty body, or a body withoutscheduledAt 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.
Authorization
| Role in the organization | This endpoint |
|---|---|
OWNER | Allowed |
ADMIN | Allowed |
MEMBER | Refused — 403 |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
scheduledAt | string | no | ISO 8601 instant with an explicit offset. Omitted, the campaign goes now |
Example
bash
curl -X POST -H "x-api-key: $AGENTMAIL_API_KEY" \
"https://www.agentsmail.io/api/v1/campaigns/$CAMPAIGN_ID/send"Response
202 Accepted— the send is queued; follow it on the campaign.401 Unauthorized— missing or invalid key, or its owner left the organization.403 Forbidden— the key's owner is amemberof the organization, not anadmin.404 Not Found— no such resource, or it belongs to another organization. The API never confirms that an id exists to a caller who has no right to it.429 Too Many Requests— over 120 requests in a minute for this key.
Response fields
| Field | Type | Description |
|---|---|---|
success | boolean | Indicates if the operation was successful |
data.status | string | send_requested when it leaves now, scheduled when a date was given |
Example response
json
{
"success": true,
"data": {
"status": "subscribed"
}
}Agent recipe
A send is asynchronous: the call returns once the departure is accepted, not once the emails are out. Poll the campaign to follow it — and stop polling when sending is disabled, or the loop never converges.js
await fetch(`${base}/campaigns/${campaignId}/send`, {method: 'POST', headers})
for (;;) {
const {data} = await (
await fetch(`${base}/campaigns/${campaignId}`, {headers})
).json()
if (!data.sendingEnabled) throw new Error('sending is paused: polling would never end')
if (data.status === 'sent' || data.status === 'failed') return data
await new Promise((resolve) => setTimeout(resolve, 5000))
}Never call this without explicit human approval for that specific campaign. Approval for one
send never covers the next one.