Guides

Running a campaign

Contacts to segment to send, with the checks that stop a bad one.

A campaign is a bulk send to a list or segment, with unsubscribe handling, reporting and a review step. It is the marketing path — not a bigger version of POST /messages.

Why not just use batch sending

Marketing mail and transactional mail ride different egress paths, deliberately. A newsletter that attracts complaints must not be able to damage the reputation carrying your password resets.

Campaigns also add what marketing mail is practically and legally required to have: List-Unsubscribe headers, a working unsubscribe link, audience management, and a report. Batch sending has none of that on purpose — a transactional receipt must not carry an unsubscribe link.

Picking the wrong one is not a style choice. It is how a domain's reputation gets spent.

Before you start: two prerequisites

Both are easy to miss, and both fail late if you skip them.

A verified sender. Not just a verified domain — the specific address has to exist as a sender and be Verified. Adding one leaves it Pending; call POST /api/v1/senders/{id}/verify and confirm the status before continuing.

Marketing review. Organizations new to marketing sending pass a one-time review before their first campaign goes out. It approves the organization, not each campaign, and it exists because shared sending reputation is exactly that — shared. The approval is not currently reachable over the API; it is a dashboard step. Everything below works before approval except the final send.

1. Contacts

curl -X POST "$MAILYTE_BASE/api/v1/contacts" \
  -H "Authorization: Bearer $MAILYTE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "email": "ada@example.com",
    "name": "Ada Lovelace",
    "attributes": { "plan": "pro", "signed_up_at": "2026-01-14" }
  }'

The field is name, not first_name — an unknown key is dropped silently, so a typo produces a nameless contact rather than an error.

Bulk load with POST /api/v1/contacts/import. Import is insert-or-skip, not an upsert: an address already present is reported as skipped_duplicate and its attributes are left alone. A re-import will not refresh anything, so update existing contacts through PUT /contacts/{id}.

One more trap: attributes is replaced wholesale on PATCH as well as PUT. Sending one attribute discards the rest. Send the whole object.

2. A list or a segment

A list is explicit membership. Create it, then add contacts by id:

curl -X POST "$MAILYTE_BASE/api/v1/contact-lists/{list_id}/members" \
  -H "Authorization: Bearer $MAILYTE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{ "contact_ids": ["01JBT8XQ2M9WYC3K4F6R7S8T9V"] }'

A segment is a rule evaluated at send time — always current, rather than current as of whenever you last edited it.

curl -X POST "$MAILYTE_BASE/api/v1/segments/preview" \
  -H "Authorization: Bearer $MAILYTE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{ "rules": [{ "field": "plan", "op": "equals", "value": "pro" }] }'

The key is op, not operator. Operators: equals, not_equals, contains, not_contains, is_set, is_not_set, plus within_days and not_within_days for engagement fields.

field targets email, name or status directly; anything else is read from attributes. Three engagement pseudo-fields read the delivery-event ledger instead — opened, clicked and emailed — so {"field": "opened", "op": "not_within_days", "value": 90} is your dormant audience. That complement deliberately includes contacts never emailed at all.

Preview returns a count and a sample and persists nothing. A rule set that matches zero contacts and one that matches your whole database look identical in an editor and very different in an inbox.

3. Create the campaign

curl -X POST "$MAILYTE_BASE/api/v1/campaigns" \
  -H "Authorization: Bearer $MAILYTE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "March product update",
    "sender_id": "01JBT8XQ2M9WYC3K4F6R7S8T9V",
    "template_id": "01JBT8XQ2M9WYC3K4F6R7S8T9W",
    "segment_ids": ["01JBT8XQ2M9WYC3K4F6R7S8T9X"]
  }'

sender_id — a verified sender's id, not a from address. list_ids and segment_ids are arrays; you can use either or both. Creating does not send.

4. Preflight

curl -X POST "$MAILYTE_BASE/api/v1/campaigns/{id}/preflight" \
  -H "Authorization: Bearer $MAILYTE_API_KEY"
{
  "ok": false,
  "blockers": [
    { "code": "no_unsubscribe", "field": "content",
      "message": "The campaign must include an unsubscribe link…" }
  ],
  "warnings": [
    { "code": "insufficient_credits", "field": "credits",
      "message": "This campaign needs 1240 sends and your balance is 300." }
  ],
  "recipient_count": 1240,
  "credit_balance": 300,
  "personalization": { "has_unsubscribe": false, "tags": [] }
}

ok is the answer. Preflight runs the same gate list the send runs, so anything it passes can be scheduled — that is guaranteed rather than hoped for. Blockers stop the send; warnings do not.

Possible blocker codes: not_draft, marketing_review_pending, sender_unverified, no_content, no_unsubscribe, no_audience, warmup_exceeded.

personalization.tags carries a per-tag count of audience members with no value for it — the cheapest way to catch a variable renamed on one side and not the other.

Send yourself a test too. It does not touch the audience and does not affect the report:

curl -X POST "$MAILYTE_BASE/api/v1/campaigns/{id}/test-send" \
  -H "Authorization: Bearer $MAILYTE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{ "emails": ["you@yourdomain.com"] }'

5. Send or schedule

curl -X POST "$MAILYTE_BASE/api/v1/campaigns/{id}/send" \
  -H "Authorization: Bearer $MAILYTE_API_KEY"

curl -X POST "$MAILYTE_BASE/api/v1/campaigns/{id}/schedule" \
  -H "Authorization: Bearer $MAILYTE_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{ "scheduled_at": "2026-09-20T09:00:00Z" }'

Both refuse with 422 and a named field if a gate fails, matching preflight exactly.

It will be slower than you expect

New marketing senders are rate-limited while they warm up, and the allowance rises over days as delivery stays healthy. A large first campaign takes hours rather than minutes.

That is the system protecting you. An unknown sender emitting fifty thousand messages in ten minutes is indistinguishable, from a receiving server's side, from a compromised account.

6. Control a running send

pause, resume and cancel act on a scheduled or sending campaign. Called on a draft or a finished campaign they refuse with 422 naming the current state; called on a campaign already in that state they are idempotent and answer 200.

Two things to know before you rely on them:

  • resume sends immediately. It does not restore a future scheduled_at — a campaign scheduled for next month goes out on resume.
  • cancel is not currently terminal. A resend can put a cancelled campaign back into sending against the full audience, with no preflight. Treat cancel as "stop now", not as "this will never go out".

7. Read the report

curl "$MAILYTE_BASE/api/v1/campaigns/{id}/report" \
  -H "Authorization: Bearer $MAILYTE_API_KEY"

Delivered, bounced, complained, opened, clicked, unsubscribed. A campaign that has never sent returns an empty report rather than an error.

Trust the first four. Treat opens and clicks as a weak signal about a population and never as evidence about a person — Handling delivery events explains why, including a month here where every recorded open turned out to be our own infrastructure.

Next

Suppressions and compliance.