Subscriber Lists
Organize subscribers into static and dynamic lists
A list is what a campaign sends to. There are two kinds, and the difference is where membership comes from:
- Static — a set of rows you manage. Somebody is a member because you put them there.
- Dynamic — a set of rules. Somebody is a member because they match, evaluated fresh every time the list is resolved.
Machine API schema
The /api/v1 surface describes itself: run
mailyard export-api-spec
and feed the document to Swagger UI, Postman,
or a client generator.
Create
POST /api/v1/subscriber-lists
{
"name": "Newsletter",
"description": "Opted in to the weekly digest",
"type": "static"
}
name is required. type is static or dynamic — nothing else is accepted.
{
"name": "Pro plan, Americas",
"type": "dynamic",
"filter_rules": [
{ "field": "custom_fields.plan", "operator": "eq", "value": "pro" },
{ "field": "timezone", "operator": "contains", "value": "America" }
]
}
At most 20 rules per list.
Filter rules
A rule is a field, an operator and a value. All rules must match — they are combined with AND, and there is no OR.
Fields
email, name, status, timezone, language, or custom_fields.<key> for anything you store yourself. A field
name outside that set never matches, so a typo produces a segment of nobody rather than an error.
Operators
| Operator | Matches when |
|---|---|
eq | Values are equal. Numeric if both parse as numbers, otherwise a case-insensitive string compare |
neq | The opposite — and true when the field is absent, which is how you select subscribers missing a field |
contains | The value appears anywhere in the field, case-insensitively |
starts_with | Case-insensitive prefix |
ends_with | Case-insensitive suffix |
gt | Numerically greater. Both sides must parse as numbers, or the rule is false |
lt | Numerically less, same condition |
exists | The field is present and not null |
Anything else is refused with unknown operator <name>.
Rules run in Go, not in SQL
Membership is computed by walking the project’s subscribers and testing each one, rather than by translating rules into
a query. That is why the operator set is small and exact, and why eq can be loose about "5" versus 5 — JSON does
not distinguish them reliably and rule authors should not have to.
It also means resolving a dynamic list is proportional to the audience, not to the match count. That is fine at the sizes a campaign runs at, and it is worth knowing before writing a segment you intend to poll.
Two filters you do not have to write
Resolution always drops subscribers whose status is not subscribed, and always drops anyone with a per-list opt-out.
So a rule on status is redundant at best, and status eq unsubscribed selects nobody.
Preview a segment
Check rules before committing them to a list:
POST /api/v1/subscriber-lists/preview-segment
{
"filter_rules": [
{ "field": "language", "operator": "eq", "value": "en" },
{ "field": "custom_fields.seats", "operator": "gt", "value": 5 }
]
}
Between 1 and 20 rules. The response counts every match and returns the first ten as a sample, because the point is to check the rules rather than page the audience:
{ "matched": 1284, "sample": [ { "id": "...", "email": "..." } ] }
matched counts subscribed members only, but it cannot subtract per-list opt-outs — there is no list yet. Expect a
campaign on the finished list to send to the same number or slightly fewer.
List and read
GET /api/v1/subscriber-lists?limit=20
GET /api/v1/subscriber-lists/{id}
The list answers every list of the project, by name, unless limit (with offset) asks for a page, and carries
total either way.
member_count comes back on the single-list route, and only for a static list. A dynamic list has no membership to
count without resolving the segment, and reporting 0 there would be a wrong answer rather than an empty one — so the
field is absent instead. opted_out_count comes back for both types: an opt-out is a stored row whichever way the
list is defined.
Update and delete
PATCH /api/v1/subscriber-lists/{id}
DELETE /api/v1/subscriber-lists/{id}
The update keeps every field the body leaves out: send only what changes. A name that is sent may not be blank, and
type cannot change after creation. Deleting takes the membership rows and per-list opt-outs with it — including the
opt-outs, so recreating a list under the same name does not restore who had left it.
Members of a static list
GET /api/v1/subscriber-lists/{id}/members?limit=20
POST /api/v1/subscriber-lists/{id}/members
DELETE /api/v1/subscriber-lists/{id}/members/{subscriberId}
GET /api/v1/subscriber-lists/{id}/opt-outs?limit=20
A member row carries opted_out_at and opt_out_reason when the subscriber opted out of this list, beside their
global status. The opt-outs route lists everyone who opted out, newest first, member or not — it is the only way to
see the opt-outs of a dynamic list, which has no members to carry them.
The add call takes either identifier — whichever your caller has to hand:
{ "subscriber_id": "0198f6a1-3c7e-7b21-9f4d-2a5c8e0b1d33" }
{ "email": "jane@example.com" }
A body naming neither is refused with 400. Adding somebody twice is not an error. A dynamic list refuses membership
calls: its members are a query result, and
there is nothing to insert into.
In the console, Add member on a list searches the audience as you type rather than offering the whole of it,
and a subscriber’s own page lists what they are on with an Add to list button for the other direction. Which
lists somebody is on is GET /api/v1/subscribers/{id}/lists.
Sign-up flows
These three take an address rather than a subscriber id, which is what a sign-up form or an automation tool actually
has. They are ordinary /api/v1 routes — an API key carries the project, so no extra header is involved.
Subscribe
POST /api/v1/subscriber-lists/subscribe
{
"list_id": "0198f6a1-3c7e-7b21-9f4d-2a5c8e0b1d33",
"email": "jane@example.com",
"name": "Jane Doe",
"language": "en",
"custom_fields": {"source": "footer form"}
}
list_id and email are required. The list must already exist — this route does not create one — and it must be
static, or the call is refused with dynamic lists have no explicit members.
The subscriber is created if new and updated if not: any of name, custom_fields, timezone and language you
supply are written, and the ones you omit are left alone.
Subscribing asserts fresh consent
The call sets status back to subscribed, clears unsubscribed_at, and lifts any per-list opt-out. So it will
re-activate somebody who previously unsubscribed, globally as well as for this list.
That is right for a form somebody just filled in and wrong for a bulk sync. Use import for the second — it leaves an existing status alone.
Answers 201:
{
"list_id": "0198f6a1-3c7e-7b21-9f4d-2a5c8e0b1d33",
"subscriber": { "id": "...", "email": "jane@example.com", "status": "subscribed" }
}
Unsubscribe
POST /api/v1/subscriber-lists/{id}/unsubscribe
{ "email": "jane@example.com", "reason": "footer link" }
Records an opt-out scoped to this list. The subscriber’s global status is untouched, so they keep receiving your
other campaigns. reason is optional and stored as written.
This is what the hosted unsubscribe page’s list-only button does. The one-click a mailbox provider sends on a campaign
message goes further and sets the status to unsubscribed as well — see
Unsubscribe & One-Click
.
Resubscribe
POST /api/v1/subscriber-lists/{id}/resubscribe
{ "email": "jane@example.com" }
Lifts the opt-out. Note what this does not do: it does not add the subscriber to a static list they were never a
member of. On a static list, resubscribing somebody who was removed as well as opted out needs the members call too.
Idempotent either way. A list that does not exist in the project answers 404, like the unsubscribe call.