Campaigns
Preview the campaign. The platform can refuse it.
Broadcasts, segments, automations, topics and templates on the same key as transactional mail. A campaign is previewed before it starts, refused if it would tip your bounce or complaint rate, and a sequence ends itself when the person unsubscribes, replies or bounces.
{
"recipient_count": 18420,
"resolved_count": 19003,
"suppressed_count": 583,
"can_send": true
}{
"recipient_count": 18420,
"can_send": false,
"blocked_reason": "Bounce rate over the last 14 days is 4.80%, above our 4% ceiling. Clean the list before sending."
}Broadcasts
Audience, segment, preview, send.
A person exists once per workspace and any number of audiences reference them, so an unsubscribe touches one row rather than a copy per list. A segment is a saved filter over an audience: engagement windows, custom fields, tags, a topic.
Save a segment
POST /v1/segments
{
"audience_id": "aud_…",
"key": "engaged-pro",
"name": "Engaged, on Pro",
"opened_within_days": 90,
"properties": [ { "property": "plan",
"operator": "equals",
"value": "pro" } ],
"exclude_tags": ["churned"],
"topic_key": "newsletter"
}Draft the broadcast
POST /v1/broadcasts
{
"audience_id": "aud_…",
"segment_id": "seg_…",
"identity_id": "idn_news…",
"name": "September update",
"subject": "What shipped in August",
"html": "…",
"topic_key": "newsletter"
}
201 { "id": "bc_…", "status": "draft" }Preview it
GET /v1/broadcasts/:id/preview
200 {
"recipient_count": 18420,
"resolved_count": 19003,
"suppressed_count": 583,
"sample": ["maya@acme.com", …],
"can_send": true
}The count is after suppression, so it promises no more delivery than will happen. The gap is exactly the people who opted out.
Send, or schedule
POST /v1/broadcasts/:id/send
{ "scheduled_at": "2026-09-09T13:00:00Z" }
202 { "id": "bc_…", "status": "scheduled" }Sending cannot be recalled once it starts, which is why the preview is a separate call. Nobody should learn the size of a campaign by starting it.
The reputation gate
Refused before it starts, not paused after.
GET /v1/broadcasts/:id/preview
{ "can_send": false,
"blocked_reason": "Complaint rate over the
last 14 days is 0.120%, above our 0.08%
ceiling. Narrow the segment to engaged
contacts." }The same check runs again immediately before the fan-out begins, in the send path rather than on a dashboard. A refused campaign is marked failed with the reason on it.
Mailbox providers and the layer underneath review an account at roughly a 5% bounce rate and a 0.1% complaint rate, and a review pauses every product’s mail, not one campaign. So the gate stops short of both: a 4% bounce ceiling and a 0.08% complaint ceiling, measured over the last 14 days.
Under a thousand sends in the window there is too little history to judge, and the campaign goes through. The alternative to refusing one campaign is having all of them paused, and the only version of that trade that works is the one made automatically.
Automations
Defined once. Ends itself.
The sequence, and the enrolment
POST /v1/automations
{
"name": "Onboarding",
"slug": "onboarding",
"identity_id": "idn_news…",
"from": "Acme <hello@news.acme.com>",
"trigger": { "kind": "api" },
"exit_on_reply": true,
"steps": [
{ "delay_minutes": 0,
"template_slug": "welcome",
"subject": "Welcome to Acme" },
{ "delay_minutes": 4320,
"template_slug": "day-3",
"subject": "Three things to try" },
{ "delay_minutes": 10080,
"template_slug": "day-10",
"subject": "How is it going?" }
]
}
POST /v1/automations/:id/enroll
{ "email": "maya@acme.com",
"variables": { "name": "Maya" } }
201 { "enrolled": true, "enrollment": { … } }
# the same call, retried
200 { "enrolled": false,
"reason": "already_enrolled" }“Enrol this person in onboarding” is a better tool call than “schedule three emails and remember to cancel them”. POST /v1/events with a name like trial_started starts every automation waiting on it, so the calling app never has to know which sequences exist.
Three automatic exits
- Unsubscribe. Ends every enrolment for that address, alongside cancelling whatever was already scheduled. Cancelling the scheduled sends alone stops one email and lets the sequence generate the next, which reads to the recipient as an unsubscribe that did not work.
- Reply. With
exit_on_reply, an answer from the person ends the sequence. A drip that keeps arriving after someone has replied is the most common way an automation reads as broken. This is only possible because the platform receives mail. - Hard bounce. A permanent bounce suppresses the address, and the next step finds the suppression and ends the enrolment rather than sending into a mailbox that does not exist.
Enrolling twice is a no-op, guaranteed by a partial unique index rather than a check, so a retried call after a timeout cannot start the sequence twice. Steps are read live from the definition: fixing a typo reaches people already mid-sequence.
Topics and templates
Opt down, not out. Escaped, or not sent.
Per-person preferences
POST /v1/topics
{ "key": "newsletter", "name": "Newsletter",
"default_subscribed": true }
POST /v1/topics/preferences
{ "email": "maya@acme.com",
"topics": { "newsletter": false } }
# a send under that topic
POST /v1/emails
{ "to": "maya@acme.com",
"topic": "newsletter", … }
202 { "skipped": true,
"reason": "unsubscribed_from_topic:newsletter" }Given only all-or-nothing, a person who wants fewer newsletters but still wants their invoices often presses spam instead, and a complaint costs the domain far more than an opt-out. A preference is keyed on the person and the topic, never on a contact row: someone on two lists has one opinion about newsletters. The one-click unsubscribe header is scoped to the topic when there is one.
Templates that refuse to half-render
POST /v1/templates
{ "slug": "welcome",
"name": "Welcome",
"subject": "Welcome, {{name}}",
"html": "<p>Hi {{name}}, you are on {{plan}}.</p>" }
POST /v1/templates/welcome/render
{ "variables": { "name": "<script>alert(1)</script>" } }
422 { "error": {
"type": "missing_variables",
"missing": ["plan"] } }
# with both supplied, the value is escaped:
"<p>Hi <script>alert(1)</script>, …"Values come from agents and end users. They are HTML-escaped on substitution, so a name like <script> cannot execute in webmail and a prompt-injected value cannot rewrite the message around it. A missing variable is an error, not an email with {{plan}} visible in it. Render without sending to check the copy first.
Campaigns on the same key as the receipts.
No separate marketing plan and no per-contact meter: see pricing. Then read why a campaign can never take a password reset down with it: deliverability.