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: total on the targeted lists, minus not_opted_in and suppressed, leaving eligible — the number that would actually be sent. (The campaign variant reports invalid instead of not_opted_in, and from_addresses as 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_address and sending_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_..." }
}
Arming is irreversible through this API — pausing a running send is an app action. Campaign and newsletter confirmation tokens are not interchangeable: a token minted by one flow is never accepted by the other.

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:

CodeStatusWhen
send_plan_changed409The 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_ready409The 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_unverified409A 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_allowed403The 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_exceeded429The 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_exceeded403The 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-After carries the wait.
  • Credits: the send consumes plan email credits, reserved at confirm. A team at its ceiling gets 402 quota_exceeded GET /usage reports 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_domains allowlist 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 with requires_approval answers send_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.