MOLTED EMAIL

Email Lists

Create subscriber lists for newsletters, announcements, and marketing broadcasts.

Lists are named groups of subscribers for sending newsletters, announcements, and marketing broadcasts. Unlike segments (dynamic, filter-based), lists use explicit subscribe/unsubscribe membership.

Create a list

POST https://api.molted.email/v1/agent/lists
CLI
molted lists create --name "Weekly Digest" --type newsletter --double-opt-in
curl
curl -X POST https://api.molted.email/v1/agent/lists \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Weekly Digest",
    "type": "newsletter",
    "doubleOptIn": true,
    "description": "Weekly product updates"
  }'

List types

TypeUse case
newsletterRecurring content (weekly digest, blog roundup)
announcementProduct updates, release notes
marketingPromotional campaigns, offers

Errors

StatusCodeMeaning
409list_name_conflictA non-archived list with this name already exists for the tenant. Use lists list to find the existing list, or pick a different name. The response body includes the conflicting name.

List all lists

GET https://api.molted.email/v1/agent/lists
CLI
molted lists list
molted lists list --limit 10 --offset 20

Get a list

GET https://api.molted.email/v1/agent/lists/:id
CLI
molted lists get <list-id>

Update a list

PATCH https://api.molted.email/v1/agent/lists/:id
CLI
molted lists update <list-id> --name "New Name" --description "Updated description"
molted lists update <list-id> --status paused

Updatable fields: name, description, type, status, doubleOptIn.

An archived list can't be reactivated this way. Changing status away from archived on an already-archived list is refused with 409 { "error": "list_archived" } -- and when that happens, the rest of the request (any other field you tried to change at the same time) is refused with it, not just status. Create a new list instead.

Archive a list

DELETE https://api.molted.email/v1/agent/lists/:id
CLI
molted lists archive <list-id>

Archiving soft-deletes the list. Subscribers are preserved but no new sends can be initiated. There is no unarchive: once a list is archived, whether by you or by a person from /app/lists (see The portal's Lists page below), it stays archived for good.

Subscribe a contact

POST https://api.molted.email/v1/agent/lists/:id/subscribers
CLI
molted lists subscribe <list-id> --email alice@example.com --name "Alice"

If double opt-in is enabled on the list, the subscriber status will be pending until they confirm via the confirmation email.

Address matching

Subscribe and unsubscribe match the address case/whitespace-insensitively, and treat +alias and Gmail-dot variants as the same address (bob+news@x.com, b.o.b@gmail.com, and Bob@X.com all resolve to the same contact as bob@x.com, whichever of those addresses was first seen). A brand-new address is stored in one canonical form: the bare address (a display name like Bob <bob@x.com> is dropped), lowercased and trimmed, with the domain in its ASCII form (a Unicode or fullwidth domain is converted, and a trailing dot is dropped). So a contact first seen as Bob <Bob@x.com.> is found later by plain bob@x.com, and the reverse.

gmail.com and googlemail.com are one mailbox, but they stay separate contacts. On one list they act as one subscriber:

  • Unsubscribe reaches both. Unsubscribing jane@gmail.com (through the API, the hosted unsubscribe link, or the portal) also unsubscribes jane@googlemail.com from that list, and the reverse.
  • No second subscription. Subscribing jane@googlemail.com while jane@gmail.com is already active on the list adds nothing: the response is the existing active subscription (its contactId is the gmail.com contact). The same on a double opt-in list while the other spelling is still pending: no second row, no second confirmation email.
  • One copy per broadcast. A list that already holds both spellings as active subscribers gets each broadcast once. The same holds for any two subscriptions of one mailbox (for example bob@x.com and bob+news@x.com).

Once an address unsubscribes from a list, subscribing it again through POST /v1/agent/lists/:id/subscribers never reactivates it -- not even resubmitting the exact same address, and not after deleting the contact and creating it again: the unsubscribe outlives the contact (kept as a one-way hash of the address per list). The same goes for the other Gmail domain: after jane@gmail.com unsubscribes, subscribing jane@googlemail.com (or a dot or +tag variant of it) to that list is refused too, and the reverse. The two stay separate contacts; only the unsubscribe carries over, and it is then shown on both. The response looks like:

{
  "listId": "...",
  "contactId": "...",
  "status": "unsubscribed",
  "reason": "previously_unsubscribed"
}

On a double opt-in list, a resubscribe attempt for an unsubscribed address is still allowed to re-open the subscription as pending and re-send the confirmation email -- but it still requires a click-through on GET /v1/lists/confirm before it goes active. A repeated resubscribe attempt while already pending does not send a second confirmation. A non-double-opt-in list has no way back in through this endpoint once unsubscribed.

A re-opened row gets its own fresh confirmation window, not whatever was left of the list's original one: the 48-hour token (above) and the 7-day pending-cleanup grace period below both count from the moment it re-entered pending, not from whenever it first ever subscribed. If you don't confirm within 48 hours, the link stops working before the row itself is cleaned up 5 days later -- resubscribe again to get a new link.

Double opt-in

When doubleOptIn: true is set on a list, subscribing a contact triggers a confirmation flow:

  1. The contact is added with status: pending.
  2. A list.subscribe.pending journey event is emitted automatically. If you have a journey configured to trigger on this event, it will send the confirmation email containing a link to GET /v1/lists/confirm?token=<token>.
  3. When the contact clicks the link, their status is updated to active and the list subscriberCount is incremented.

Confirmation tokens expire after 48 hours. Pending subscriptions that are never confirmed are automatically cleaned up 7 days after they most recently entered pending -- deleted if they have no prior history on this list, or reverted back to unsubscribed if they do (see Address matching above for the resubscribe-while-unsubscribed case).

Setting up the confirmation journey

Create a journey in the portal with trigger event list.subscribe.pending. Use the confirmationUrl field from the event payload in your confirmation email template:

Event payload
{
  "listId": "...",
  "listName": "Weekly Digest",
  "contactId": "...",
  "confirmationUrl": "https://api.molted.email/v1/lists/confirm?token=..."
}

The journey step that sends the confirmation email should include {{ payload.confirmationUrl }} as the confirm link.

The subscribe_to_list inbound-hook action reuses this same flow (its own requireConfirmation option, independent of this list's doubleOptIn setting) -- confirming a pending subscription works the same way regardless of which path created it.

Subscription statuses

StatusMeaning
pendingAwaiting confirmation (double opt-in only)
activeConfirmed subscriber
unsubscribedOpted out

Unsubscribe a contact

POST https://api.molted.email/v1/agent/lists/:id/unsubscribe
CLI
molted lists unsubscribe <list-id> --email alice@example.com

This unsubscribes every subscription of that mailbox on the list, active or pending: any spelling of the address (case, +tag, Gmail dots, a display-name form) and the other Gmail domain (gmail.com / googlemail.com). The hosted unsubscribe link in a broadcast does the same for the address it was sent to.

List subscribers

GET https://api.molted.email/v1/agent/lists/:id/subscribers
CLI
molted lists subscribers <list-id>
molted lists subscribers <list-id> --limit 100 --offset 0

members is an alias for subscribers - both commands do exactly the same thing:

CLI
molted lists members <list-id>   # equivalent to 'subscribers'

Note: The CLI help screen shows subscribers|members using pipe notation to indicate the alias. This is not a special syntax you need to type - use either subscribers or members as the command name.

Bulk import subscribers

CLI
molted lists import <list-id> --file subscribers.csv
molted lists import <list-id> --file subscribers.json

The file format is determined by extension: .csv for CSV, anything else is parsed as JSON.

CSV format

The first row must be a header row. The email column is required. name is optional. Any additional columns are collected into a metadata object on the subscriber.

subscribers.csv
email,name,plan,source
alice@example.com,Alice,pro,website
bob@example.com,Bob,starter,referral
charlie@example.com,,free,import

JSON format

Must be a top-level JSON array. Each element is a subscriber object. Fields beyond email and name should go inside a metadata object.

subscribers.json
[
  { "email": "alice@example.com", "name": "Alice", "metadata": { "plan": "pro" } },
  { "email": "bob@example.com", "name": "Bob", "metadata": { "plan": "starter" } }
]

Subscriber fields

FieldTypeRequiredNotes
emailstringYesRows/objects with a missing or empty email are silently skipped.
namestringNoDisplay name.
Additional CSV columnsstringNoCollected into a metadata object automatically.
metadata (JSON)objectNoArbitrary key-value pairs. Passed through as-is.

Validation errors

ErrorCause
Cannot read file: <path>File does not exist or cannot be read.
File must contain a valid JSON array of subscriber objectsJSON is malformed or is not an array.
No subscribers found in file (all rows missing email)Every row/object is missing the email field.

Import response

Files are processed in chunks of 500. If a chunk fails, the import stops - earlier chunks may already be committed.

Success
{
  "total": 153,
  "subscribed": 150,
  "pending": 0,
  "skipped": 3,
  "failed": 0,
  "skippedReasons": {
    "previously_unsubscribed": 2,
    "suppressed": 1
  }
}

skippedReasons is only present when skipped is greater than 0.

Send to a list

POST https://api.molted.email/v1/agent/lists/:id/send
CLI
molted lists send <list-id> --template <template-id> --yes
molted lists send <list-id> --template <template-id> --mailbox <mailbox-id> --payload '{"promo_code": "SPRING25"}' --yes

Always pass --yes when sending from an agent or script. Without it, the CLI shows an interactive confirmation prompt that will hang any non-interactive process. The CLI auto-skips the prompt in non-TTY environments and when CI or MOLTED_NONINTERACTIVE is set, but some agent runtimes (including Claude Code and other tool-use shells) allocate a pseudo-TTY -- so --yes is the most reliable way to ensure your send completes without blocking.

Broadcasts are sent sequentially to each active subscriber. For each recipient, the system:

  • Renders the template with subscriberEmail, subscriberName, listName, and unsubscribeUrl injected into the payload
  • Generates a unique dedupe key (list-{listId}-{sendId}-{contactEmail}) to prevent duplicate sends
  • Sends once per mailbox: two active subscriptions of the same mailbox (the gmail.com and googlemail.com spellings, or a +tag or Gmail-dot variant) get one copy, sent to the older subscription
  • Attaches RFC 8058 List-Unsubscribe and List-Unsubscribe-Post headers for one-click unsubscribe
  • Runs every send through the full policy engine (rate limits, suppressions, cooldowns)

The response includes sent, blocked, failed, and blockedReasons counts:

  • sent - send requests accepted by the policy engine (queued, scheduled, or pending approval)
  • blocked - sends the policy engine refused for this recipient (e.g. suppressed, trial_not_activated, rate_limited)
  • failed - sends that threw an upstream error (non-policy)
  • blockedReasons - map of block reason to count, e.g. { "suppressed": 2 }

Partial failures do not abort the broadcast - the system continues sending to remaining subscribers. When every subscriber was blocked or errored (sent === 0), the list-send record is marked failed so agents can distinguish "everyone was blocked" from "everyone was delivered".

Limits: Sequential broadcasts support up to 500 active subscribers per list. Lists exceeding this limit will return an error. For larger lists, contact support.

View send history

GET https://api.molted.email/v1/agent/lists/:id/sends
CLI
molted lists sends <list-id>

The portal's Lists page

In the portal, a person watches what you've built: Lists (/app/lists) shows every list with its subscriber count and who built it, and /app/lists/<id> shows one list's subscribers and the sends that used it. It's read-only and tighten-only -- the person can take one subscriber off a list (the same unsubscribe as above, so the list's subscriberCount and this page agree) or archive the list (same effect as Archive a list: no new sends or subscribers). There's no create, add or import on that page; those stay yours through this API.

CLI output format

All list commands use a structured JSON envelope for agent-friendly parsing:

Success
{
  "status": "success",
  "message": "List 'Weekly Digest' created successfully.",
  "data": { "id": "...", "name": "Weekly Digest", "subscriberCount": 0 }
}
Error
{
  "status": "error",
  "message": "Failed to create list",
  "details": { "error": "..." }
}

For bulk import response structure, see Bulk import subscribers.