Overview
Create and send email campaigns to subscriber lists
A campaign renders one template against every member of a subscriber list and delivers the results. It is bulk mail, and it differs from a batch send in every way that matters: the audience is resolved for you, the send is throttled, it can be paused, and it tracks engagement.
Machine API schema
The /api/v1 surface describes itself: run
mailyard export-api-spec
and feed the document to Swagger UI, Postman,
or a client generator.
Create
POST /api/v1/campaigns
curl -X POST http://localhost:3000/api/v1/campaigns \
-H "Authorization: Bearer myk_..." \
-H "Content-Type: application/json" \
-d '{
"name": "April dispatch",
"from_email": "newsletter@example.com",
"from_name": "Acme Industrial",
"template_id": "0198f6a1-3c7e-7b21-9f4d-2a5c8e0b1d33",
"list_id": "0198f6a2-7b19-7d02-9c31-4e8f1a6b3c22",
"language": "en",
"template_data": {"month": "April", "featured_url": "https://example.com/april"},
"send_rate": 600
}'
Four fields are required — name, from_email, template_id and list_id — and the one most people
expect to see among them is not there.
subject is a fallback, not the subject line
The subject comes from the template’s localization, like everything else about the content. The campaign’s subject
field is used only when that localization renders an empty one.
So a campaign with a carefully worded subject and a template that has its own will send the template’s. Change the
copy where the copy lives.
The rest
| Field | Default | Does |
|---|---|---|
from_name | — | Display name on the From header |
reply_to | — | Reply-To address, for a campaign sent from a no-reply mailbox. Not required to be verified |
subject | — | Fallback subject, as above |
language | — | Localization to render. Falls through the usual four steps per subscriber |
template_data | — | Campaign-wide render values, up to 100 keys |
smtp_group | project default | Slug of the server pool to send through |
send_rate | 0 | Emails per minute. 0 is unthrottled |
send_at_local_time | false | Deliver at the scheduled wall-clock time in each subscriber’s own timezone |
ab_test_enabled | false | Turn on A/B testing |
ab_variants | — | Up to 5 variants |
smtp_group is worth setting. Bulk mail on its own pool is the usual arrangement, so a campaign that burns an IP’s
reputation does not take your transactional mail down with it.
template_data is merged under each subscriber’s own custom fields, and email and name are written last from
the subscriber record — so a subscriber field beats a campaign-wide one, and neither can override the recipient’s own
address.
Scheduling is not a property of the campaign. Pass scheduled_at to the send call instead — see
Sending
. A campaign always renders the template’s active version, and there is no way to
pin an older one.
Statuses
| Status | Means | Editable |
|---|---|---|
draft | Created, never started | Yes |
scheduled | A send was requested for a future time | No |
sending | The runner is working through the audience | No |
paused | Stopped part-way, resumable | No |
sent | Every message reached a terminal state | No |
cancelled | Stopped permanently | No |
Only draft can be edited, and only draft or scheduled can be sent. Anything else answers 409 with “campaign is
already running or finished”.
List
GET /api/v1/campaigns
Every campaign in the project, newest first.
This route takes no parameters
No limit, no offset, no status filter — the whole list comes back in one response, and each entry is the campaign
record alone. Per-campaign statistics are not included: those come from the single-campaign route below.
Campaigns are a list somebody made by hand, so it stays small in practice. Filter client-side.
Read one
GET /api/v1/campaigns/{id}
This is the route with the numbers on it:
{
"campaign": { "id": "...", "name": "April dispatch", "status": "sending" },
"stats": { "pending": 2100, "queued": 500, "sent": 2300, "failed": 50, "skipped": 50 },
"stats_by_variant": { "A": { "sent": 1150 }, "B": { "sent": 1150 } },
"engagement": { "opened": 890, "clicked": 214 }
}
stats counts messages, one per recipient:
pending— waiting for a batchqueued— an email exists in the delivery queuesent,failed— mirroring that email’s fateskipped— the recipient was suppressed, or the campaign was cancelled before their turn
engagement is the unique recipient view — how many people opened, not how many opens there were. Those counters
are aggregated as the send runs, so they survive the tracking-event retention sweep. The per-day series on
GET /api/v1/campaigns/{id}/analytics come from the raw event log and only reach back as far as retention keeps it.
Manage
| Route | Does |
|---|---|
PATCH /api/v1/campaigns/{id} | Edit — draft only |
POST /api/v1/campaigns/{id}/duplicate | Copy the definition as a fresh draft |
POST /api/v1/campaigns/{id}/send | Start, or schedule — see Sending |
POST /api/v1/campaigns/{id}/pause | Stop between batches |
POST /api/v1/campaigns/{id}/resume | Carry on |
POST /api/v1/campaigns/{id}/cancel | Stop for good |
GET /api/v1/campaigns/{id}/messages | The per-recipient rows, with addresses |
GET /api/v1/campaigns/{id}/analytics | Per-link click tallies and daily series |
DELETE /api/v1/campaigns/{id} | Remove the campaign and its messages |
Duplicate is the way to iterate: a sent campaign cannot be edited or re-run, so the second attempt is a copy with the
audience or the wording changed.