Newsletters
One-off broadcasts. Pick a template, target a list, schedule for later or send now. Per-list unsubscribes, suppression filtering, preview and test-send all built in.
What a newsletter is
A persistent broadcast object that points at one template + one list. Use it for weekly newsletters, launch announcements, sale promos — anything where you compose once and ship to a known audience.
For ongoing lifecycle email (welcome series, abandoned cart, re-engagement), use Automations instead.
Lifecycle
create → draft ──schedule──▶ scheduled ──worker fires──▶ sending ──reconcile──▶ sent
│ │
delete cancel ──▶ cancelled - draft — saved but not scheduled. Editable; can be deleted outright.
- scheduled — has a
scheduledAt. The worker fires when due. Editable; cancellable. - sending — fire-phase complete; scheduled_sends queued. Cancel still works (cancels remaining pending sends).
- sent — reconciler verified all sends terminal. Counts finalized. Read-only.
- cancelled — terminal; sends stopped. Counts reflect what made it out.
Create
POST /api/v1/newsletters
{
"name": "Weekly digest — 2026-W19",
"templateSlug": "weekly-digest",
"listId": "list_01HXAB...",
"subjectOverride": "Your week in 60 seconds", // optional
"fromAddressOverride": "hello@news.acme.com", // optional
"scheduledAt": "2026-05-15T13:00:00Z" // omit → draft
} Omit scheduledAt to land in draft; include it to land in scheduled. Send-now in the UI sets it to now() so the worker fires on the next tick.
Validation rules
name— 1–200 chars.templateSlug— must match an active template for your product.listId— must belong to your product.subjectOverride— optional, ≤ 500 chars, plain text (not Handlebars).fromAddressOverride— optional, valid email on a verified domain.scheduledAt— ISO-8601, must be ≥ now() − 60s.
Schedule, send-now, cancel
| Action | Endpoint | Allowed when |
|---|---|---|
| Schedule | POST /newsletters/{id}/schedule | draft |
| Send now | POST /newsletters/{id}/send-now | draft, scheduled |
| Cancel | POST /newsletters/{id}/cancel | scheduled, sending |
| Edit | PATCH /newsletters/{id} | draft, scheduled (409 otherwise) |
| Delete | DELETE /newsletters/{id} | draft only |
What happens at fire time
When scheduledAt <= now() the worker picks up the campaign atomically (Postgres FOR UPDATE SKIP LOCKED), pins the currently-active template version onto the row, enumerates list memberships, filters out suppressed contacts, and inserts one scheduled_send per remaining recipient. Status flips to sending.
From there the regular send queue drains exactly like transactional traffic — same workers, same retry policy, same logs.
Counts you'll see
recipientCount— list members at fire time, minus suppressions.suppressedCount— members who were globally suppressed and skipped.sentCount— sends that landed (250 OK from SMTP).failedCount— sends that failed terminally (hard bounce, invalid address, etc.).
A campaign reconciles to sent when no scheduled_sends remain in pending status.
Preview and test-send
Before broadcasting, render the campaign exactly as a recipient would see it:
POST /api/v1/newsletters/{id}/preview
{ "contactId": "contact_01HXAB..." } // optional; uses synthetic if omitted
→ { "subject": "...", "html": "...", "fromAddress": "..." } The preview applies subjectOverride and fromAddressOverride if set. The unsubscribeUrl renders as the literal #preview so no real token is exposed.
Send a test email
POST /api/v1/newsletters/{id}/test-send
{ "to": "you@example.com" }
→ { "to": "you@example.com", "sentAt": "2026-05-12T10:00:00Z" } Test sends route through tenant SMTP exactly like a real send, but do not write to send logs and do not increment newsletter counts. Allowed only when the newsletter is in draft or scheduled. Rate-limited to 10/min/newsletter.
Per-list vs global unsubscribe
The footer partial renders a confirmation page that lets the recipient choose:
- This list (default) — removes them from
list_membershipsfor the specific list; cancels their pending sends from this newsletter. - All emails from this sender — same as above plus writes a row to
contact_suppressionsso they're skipped from every future newsletter on your product.
RFC 8058 one-click unsubscribes (the Gmail/Yahoo "Unsubscribe" button next to the sender name) always default to per-list — mail clients can't pass scope, and a one-click global wipe would surprise users.
Common patterns
Weekly newsletter from a curated list
- Create a list, import subscribers via CSV import.
- Build a template (or AI-generate one).
- Each week:
POST /newsletterswith a freshscheduledAt; the worker handles the rest.
Segmented launch announcement
- Build a list from a filter (e.g. "plan=pro AND created_after=2026-01-01") — see Lists from a filter.
- Materialize it (max 50,000 contacts).
- Send an immediate newsletter pointing at the new list.
Related
- Build a list from a filter
- Compose a template
- Lifecycle automations (recurring, not one-off)