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/listsmolted lists create --name "Weekly Digest" --type newsletter --double-opt-incurl -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
| Type | Use case |
|---|---|
newsletter | Recurring content (weekly digest, blog roundup) |
announcement | Product updates, release notes |
marketing | Promotional campaigns, offers |
Errors
| Status | Code | Meaning |
|---|---|---|
| 409 | list_name_conflict | A 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/listsmolted lists list
molted lists list --limit 10 --offset 20Get a list
GET https://api.molted.email/v1/agent/lists/:idmolted lists get <list-id>Update a list
PATCH https://api.molted.email/v1/agent/lists/:idmolted lists update <list-id> --name "New Name" --description "Updated description"
molted lists update <list-id> --status pausedUpdatable 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/:idmolted 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/subscribersmolted 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 unsubscribesjane@googlemail.comfrom that list, and the reverse. - No second subscription. Subscribing
jane@googlemail.comwhilejane@gmail.comis already active on the list adds nothing: the response is the existingactivesubscription (itscontactIdis the gmail.com contact). The same on a double opt-in list while the other spelling is stillpending: 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.comandbob+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:
- The contact is added with
status: pending. - A
list.subscribe.pendingjourney event is emitted automatically. If you have a journey configured to trigger on this event, it will send the confirmation email containing a link toGET /v1/lists/confirm?token=<token>. - When the contact clicks the link, their status is updated to
activeand the listsubscriberCountis 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:
{
"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
| Status | Meaning |
|---|---|
pending | Awaiting confirmation (double opt-in only) |
active | Confirmed subscriber |
unsubscribed | Opted out |
Unsubscribe a contact
POST https://api.molted.email/v1/agent/lists/:id/unsubscribemolted lists unsubscribe <list-id> --email alice@example.comThis 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/subscribersmolted lists subscribers <list-id>
molted lists subscribers <list-id> --limit 100 --offset 0members is an alias for subscribers - both commands do exactly the same thing:
molted lists members <list-id> # equivalent to 'subscribers'Note: The CLI help screen shows
subscribers|membersusing pipe notation to indicate the alias. This is not a special syntax you need to type - use eithersubscribersormembersas the command name.
Bulk import subscribers
molted lists import <list-id> --file subscribers.csv
molted lists import <list-id> --file subscribers.jsonThe 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.
email,name,plan,source
alice@example.com,Alice,pro,website
bob@example.com,Bob,starter,referral
charlie@example.com,,free,importJSON 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.
[
{ "email": "alice@example.com", "name": "Alice", "metadata": { "plan": "pro" } },
{ "email": "bob@example.com", "name": "Bob", "metadata": { "plan": "starter" } }
]Subscriber fields
| Field | Type | Required | Notes |
|---|---|---|---|
email | string | Yes | Rows/objects with a missing or empty email are silently skipped. |
name | string | No | Display name. |
| Additional CSV columns | string | No | Collected into a metadata object automatically. |
metadata (JSON) | object | No | Arbitrary key-value pairs. Passed through as-is. |
Validation errors
| Error | Cause |
|---|---|
Cannot read file: <path> | File does not exist or cannot be read. |
File must contain a valid JSON array of subscriber objects | JSON 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.
{
"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/sendmolted lists send <list-id> --template <template-id> --yes
molted lists send <list-id> --template <template-id> --mailbox <mailbox-id> --payload '{"promo_code": "SPRING25"}' --yesAlways 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, andunsubscribeUrlinjected 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.comandgooglemail.comspellings, or a+tagor Gmail-dot variant) get one copy, sent to the older subscription - Attaches RFC 8058
List-UnsubscribeandList-Unsubscribe-Postheaders 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/sendsmolted 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:
{
"status": "success",
"message": "List 'Weekly Digest' created successfully.",
"data": { "id": "...", "name": "Weekly Digest", "subscriberCount": 0 }
}{
"status": "error",
"message": "Failed to create list",
"details": { "error": "..." }
}For bulk import response structure, see Bulk import subscribers.