API Reference
API Reference
GitHub ↗
Campaigns

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

FieldDefaultDoes
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
headers—Custom headers on every message of the campaign, up to 20. Over the project’s defaults, under nothing - see the three layers
smtp_groupproject defaultSlug of the server pool to send through
send_rate0Emails per minute. 0 is unthrottled
send_at_local_timefalseDeliver at the scheduled wall-clock time in each subscriber’s own timezone
ab_test_enabledfalseTurn on A/B testing
ab_variants—Up to 5 variants
unsubscribe_disabledfalseSend with no List-Unsubscribe headers and no unsubscribe link
disable_signingfalseSend without the From address’s S/MIME or PGP signature, when the address holds one - see Signed Mail

unsubscribe_disabled makes the mail worse

Gmail and Yahoo filter bulk mail that lacks List-Unsubscribe rather than bouncing it, so the failure is invisible from here, and {{ mailyard_unsubscribe_url }} in the template renders empty. It exists for the case where the opt-out is handled entirely elsewhere. Opens, clicks and the web view keep working.

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

StatusMeansEditable
draftCreated, never startedYes
scheduledA send was requested for a future timeNo
sendingThe runner is working through the audienceNo
pausedStopped part-way, resumableNo
sentEvery message reached a terminal stateNo
cancelledStopped permanentlyNo

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, with total.

ParamNotes
statusOne status or several separated by commas: draft, scheduled, sending, paused, sent, cancelled
qPart of the name, without regard to case
limit, offsetA page, at most 200 rows. Without limit the whole list comes back, which is what the console wants

Each entry is the campaign record alone. Per-campaign statistics are not included: those come from the single-campaign route below.

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,
        "unsubscribed": 12,
        "sent": 2300
    }
}

stats counts messages, one per recipient:

  • pending — waiting for a batch
  • queued — an email exists in the delivery queue
  • sent, failed — mirroring that email’s fate
  • skipped — 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. unsubscribed counts recipients who unsubscribed through this campaign’s link, whichever scope they chose on the page, and each message carries its unsubscribed_at — see Unsubscribe & One-Click . 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

RouteDoes
PATCH /api/v1/campaigns/{id}Edit — draft only. Fields the body leaves out keep their value
POST /api/v1/campaigns/{id}/duplicateCopy the definition as a fresh draft
POST /api/v1/campaigns/{id}/sendStart, or schedule — see Sending
POST /api/v1/campaigns/{id}/pauseStop between batches
POST /api/v1/campaigns/{id}/resumeCarry on
POST /api/v1/campaigns/{id}/cancelStop for good
GET /api/v1/campaigns/{id}/messagesThe per-recipient rows, with addresses. ?email= answers one subscriber’s row
GET /api/v1/campaigns/{id}/analyticsPer-link click tallies and daily series. from and to bound the series
POST /api/v1/campaigns/{id}/previewRender the message one subscriber would get
DELETE /api/v1/campaigns/{id}Remove the campaign and its messages

Preview

POST /api/v1/campaigns/{
    id
}/preview
{
    "subscriber_id": "0198f6a1-3c7e-7b21-9f4d-2a5c8e0b1d33",
    "variant": "B"
}

Renders the campaign the way the runner will: the variant’s template and subject if one is named (the first variant when none is), the subscriber’s language, and data merged as campaign template_data under their custom fields under their email and name. Both fields are optional. Without subscriber_id the campaign’s template_data renders alone, which is what a subscriber with no custom fields receives. The template’s sample data is never used here: a send never reads it, so a preview filled from it would look right until the mail went out. A value a campaign needs has to be in its own template_data. The answer is preview with subject, html and text. The web view and unsubscribe links are left out, since no message exists for them to open. Nothing is stored, and it needs campaigns:read only.

The console has this as Preview on the campaign page, with a subscriber picker that searches the audience.

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.