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:
resumesends immediately. It does not restore a futurescheduled_at— a campaign scheduled for next month goes out on resume.cancelis not currently terminal. Aresendcan put a cancelled campaign back intosendingagainst 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.