API Reference
API Reference
GitHub
Subscribers

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

OperatorMatches when
eqValues are equal. Numeric if both parse as numbers, otherwise a case-insensitive string compare
neqThe opposite — and true when the field is absent, which is how you select subscribers missing a field
containsThe value appears anywhere in the field, case-insensitively
starts_withCase-insensitive prefix
ends_withCase-insensitive suffix
gtNumerically greater. Both sides must parse as numbers, or the rule is false
ltNumerically less, same condition
existsThe 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}

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.

Update and delete

PATCH  /api/v1/subscriber-lists/{id}
DELETE /api/v1/subscriber-lists/{id}

name is required on the update. 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}

The add call takes either identifier — whichever your caller has to hand:

{ "subscriber_id": "0198f6a1-3c7e-7b21-9f4d-2a5c8e0b1d33" }
{ "email": "jane@example.com" }

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.

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.

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.