Concepts
Sending
Sending mail is the one irreversible thing this API does, so it is deliberately two calls: a preview that resolves exactly what would happen, and a confirmation — bound to that exact plan — that makes it happen. Campaigns and newsletters share the same flow.
Why sending is two calls
A single POST /send asks you to trust that what you think the audience is matches what the send worker will resolve minutes later. The two-step flow removes the trust: the preview runs every send gate read-only, resolves the recipient set after opt-in and suppression filtering, and returns the numbers plus a single-use confirmation token bound to that resolved plan. The confirm verifies the token, re-runs every gate, re-resolves the plan, and refuses if anything drifted. The send that happens is provably the send that was shown.
This is stronger than a dry run: a dry run's answer is advisory, while the preview's is enforced — which is why the send endpoints do not accept dry_run at all. It also keeps a human in the loop by construction: an automation (or an AI agent) cannot one-shot a send, because the confirming call needs a token that only a preview mints, and the design intent is that someone reads the impact between the two. OAuth clients cannot hold the send tier at all — only an API key minted by a team member can confirm.
Step 1: preview
POST /newsletters/{newsletterId}/send-preview (or POST /campaigns/{campaignId}/send-preview), scope newsletters:send / campaigns:send. Broadcasts only: a drip sequence is refused — activating one enrols subscribers perpetually, which a one-shot confirmation cannot honestly represent. If a targeted segment's stored conditions cannot be evaluated identically to the dashboard, the preview refuses with filter_unsupported rather than publish a number the send worker might disagree with.
# Step 1 of 2 — nothing is scheduled or sent by this call.
# Idempotency-Key is REQUIRED: a retried preview must not mint a
# second token.
curl -X POST "https://api.mailneo.co/api/v2/newsletters/$NEWSLETTER_ID/send-preview" \
-H "x-api-key: $MAILNEO_API_KEY" \
-H "Idempotency-Key: preview-2026-08-04-a"{
"data": {
"object": "newsletter_send_preview",
"newsletter_id": "...",
"recipients": { "total": 1204, "not_opted_in": 12, "suppressed": 38, "eligible": 1154 },
"credits_required": 1154,
"send_cap": { "limit": 2000, "used_today": 300, "remaining": 1700 },
"from_address": "news@yourdomain.com",
"sending_account_address": "news@yourdomain.com",
"complaint_rate": { "rate": 0.0004, "threshold": 0.002, "window_days": 30 },
"confirmation": {
"token": "...",
"expires_at": "2026-08-04T12:15:00Z"
}
},
"meta": { "request_id": "req_..." }
}The impact block
recipients— the resolved audience:totalon the targeted lists, minusnot_opted_inandsuppressed, leavingeligible— the number that would actually be sent. (The campaign variant reportsinvalidinstead ofnot_opted_in, andfrom_addressesas a list, since a campaign can rotate several sending accounts.)credits_required— email credits the send would consume; they are reserved at confirm, not at preview.send_cap— the key's daily cap and today's headroom.from_addressandsending_account_address— the identity the mail would carry.complaint_rate— where the team stands against the complaint breaker.confirmation— the single-use token and its expiry, 15 minutes out.
Step 2: confirm
Once a human has read the numbers, pass the token to POST /newsletters/{newsletterId}/send (or the campaign equivalent). The response is a 202: credits are reserved and the resource is armed — the same scheduling flip the app performs — and the scheduler starts the send within about a minute, unless the newsletter restricts its send days, in which case it starts on the next allowed day.
# Step 2 of 2 — made by a person who has read the impact above.
curl -X POST "https://api.mailneo.co/api/v2/newsletters/$NEWSLETTER_ID/send" \
-H "x-api-key: $MAILNEO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: send-2026-08-04-a" \
-d "{ \"confirmation_token\": \"$TOKEN\" }"{
"data": {
"object": "newsletter_send",
"newsletter_id": "...",
"status": "SCHEDULED",
"recipients": 1154,
"credits_reserved": 1154,
"send_at": "2026-08-04T12:03:00Z",
"send_cap": { "limit": 2000, "used_today": 1454, "remaining": 546 }
},
"meta": { "request_id": "req_..." }
}Plan drift: 409 send_plan_changed
The token is invalidated by any change to the plan between preview and confirm: a subscriber joining or leaving a targeted list, a suppression or unsubscribe landing, content or sending-identity edits, or a different API key confirming. The confirm then answers 409 send_plan_changed — never a silent send of the drifted plan. The remedy is always the same: call send-preview again, review the new impact, and confirm with the new token. On a busy list this can genuinely loop a few times; that is the feature working, not failing — every loop means the numbers you were shown had stopped being true.
The gates
Every gate runs read-only at preview and re-runs, enforced, at confirm. Each has its own code because each has a different remedy — re-preview, fix the account, wait for the day to roll over, or stop and look at list hygiene:
| Code | Status | When |
|---|---|---|
| send_plan_changed | 409 | The campaign no longer matches the plan the confirmation token was issued against — a recipient, suppression, content or sending-account change landed between preview and confirm. Call send-preview again, review the new impact, and confirm with the new token. |
| campaign_not_ready | 409 | The campaign is a draft but not sendable: it is missing a subject, content, a sending account, or has no eligible recipients after suppression filtering. Complete the draft and preview again. |
| sending_domain_unverified | 409 | A sending account referenced by the campaign is not ACTIVE, or no longer exists in your team. Reconnect or verify the account in the app, then preview again. |
| send_domain_not_allowed | 403 | The key's send_domains allowlist does not cover every address the campaign would send from. An empty allowlist means the key cannot send at all, whatever its scopes — mint a key that names the domains it may send from. |
| send_cap_exceeded | 429 | The key's daily send cap has no headroom for this send. The cap is counted across the key's whole rotation lineage — rotating a key never resets it — and resets at UTC midnight; Retry-After carries the wait. |
| complaint_rate_exceeded | 403 | The team's complaint rate over the last 30 days is at or above 0.20% of delivered mail, so API sends are paused. Review list hygiene and consent; the breaker lifts as the rate falls. |
- Caps: the daily send cap belongs to the key and is counted across its whole rotation lineage — rotating a key never resets it. It resets at UTC midnight, and
Retry-Aftercarries the wait. - Credits: the send consumes plan email credits, reserved at confirm. A team at its ceiling gets
402 quota_exceeded—GET /usagereports the current period so an integration can self-throttle instead of discovering the ceiling here. - Complaints: a team complaint rate at or above 0.20% of delivered mail over 30 days pauses API sends entirely (
complaint_rate_exceeded). The breaker lifts as the rate falls; the fix is consent and list hygiene, not retries. - Key-level controls:the key's
send_domainsallowlist must cover every from-address, and an empty allowlist means the key cannot send at all, whatever its scopes. Test keys never send, and a key minted withrequires_approvalanswerssend_not_permitted.
Drafts-only writes
The write scopes cannot reach any of this. campaigns:write and newsletters:write create drafts only: send-shaped fields (status, send_at, send_days, the drip flags) are refused by name with a 400, never silently ignored, and mutations re-check the draft state inside the write itself — racing an arming flip is a 409, not a lost update. The split means a key that drafts content all day holds no power to send it, and the one scope that can (newsletters:send / campaigns:send) can be granted to exactly the process — or person — that should hold it. See the scope model, and walk the whole flow end-to-end in the quickstart.