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

ActionEndpointAllowed when
SchedulePOST /newsletters/{id}/scheduledraft
Send nowPOST /newsletters/{id}/send-nowdraft, scheduled
CancelPOST /newsletters/{id}/cancelscheduled, sending
EditPATCH /newsletters/{id}draft, scheduled (409 otherwise)
DeleteDELETE /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_memberships for the specific list; cancels their pending sends from this newsletter.
  • All emails from this sender — same as above plus writes a row to contact_suppressions so 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

  1. Create a list, import subscribers via CSV import.
  2. Build a template (or AI-generate one).
  3. Each week: POST /newsletters with a fresh scheduledAt; the worker handles the rest.

Segmented launch announcement

  1. Build a list from a filter (e.g. "plan=pro AND created_after=2026-01-01") — see Lists from a filter.
  2. Materialize it (max 50,000 contacts).
  3. Send an immediate newsletter pointing at the new list.

Related