Lists & Segments
The audience API exposes the same Lists, Segments, and custom attributes your dashboard shows. There are three resources:
- Lists — static audiences with explicit membership. A contact is in a List because it was added, and stays until it’s removed.
- Segments — dynamic audiences defined by a SendQL predicate. Membership is computed from each contact’s attributes and engagement, and is re-evaluated continuously — you never add members by hand.
- Attributes — the registry of custom attribute definitions (name, type, optional enum values). This is the schema that Segment predicates reference as
attr.<name>.
All three are writable, each behind its own .manage scope. Nothing on this surface is read-only because it came from somewhere else: a definition belongs to your organization and any caller holding the scope can author it.
- Segments — create, edit, delete, pause/resume and force-evaluate with
api.segments.manage; lint and size a candidate predicate first with the two non-mutatingPOSTs underapi.segments.view. See Managing Segment definitions. - Lists — create, edit and delete List definitions with
api.lists.manage, while membership stays on the contacts surface (listson/v1/contacts). See Managing List definitions. - Attributes — create, edit, delete and dry-run-preview definitions with
api.attributes.manage. Editing the schema can affect the Segments that reference it, so every write reports its mutation class.
What a Segment’s members are is the one thing you cannot write: membership is the live truth value of the predicate, recomputed, never assigned.
Membership keys both ways
List and Segment members are returned by contact_id and email. To answer “is this one contact a member?” without paging the whole audience, use the membership-check endpoint — it returns 200 with is_member: true|false and only 404s when the List or Segment itself doesn’t exist.
How it maps to the dashboard
| Dashboard | API |
|---|---|
| Audience → Lists | GET /v1/lists |
| Create a List | POST /v1/lists |
| A single List’s detail page | GET /v1/lists/{id} · GET /v1/lists/{id}/members |
| Edit / delete a List | PUT /v1/lists/{id} · DELETE /v1/lists/{id} |
| Audience → Segments | GET /v1/segments |
| Create a Segment | POST /v1/segments |
| A single Segment’s detail page | GET /v1/segments/{id} · GET /v1/segments/{id}/members |
| Edit / delete a Segment | PUT /v1/segments/{id} · DELETE /v1/segments/{id} |
| Pause or resume a Segment | PUT /v1/segments/{id}/status |
| Re-evaluate now | POST /v1/segments/{id}/evaluate |
| The Segment predicate editor’s live preview | POST /v1/segments/preview · POST /v1/segments/validate |
| Custom attribute definitions | GET /v1/attributes |
| Version history and restore | GET …/versions · POST …/versions/{version}/restore |
Scopes
Six scopes gate this surface, each granted by the matching dashboard permission:
| Scope | Grants | PII |
|---|---|---|
api.lists.view | /v1/lists, /v1/lists/{id}, /v1/lists/{id}/members, and the membership check | Yes — returns member contact ids and emails. Grant deliberately. |
api.lists.manage | Create (POST /v1/lists), edit (PUT /v1/lists/{id}), and delete (DELETE /v1/lists/{id}) List definitions | No — definition-only (name, key, description). Membership add/remove is a separate write on the contacts surface (api.contacts.manage). |
api.segments.view | /v1/segments, /v1/segments/{id}, /v1/segments/{id}/members, the membership check, the version reads, and the two dry-runs /v1/segments/preview and /v1/segments/validate | Yes — returns member contact ids/emails plus the predicate itself. |
api.segments.manage | Create (POST /v1/segments), edit (PUT /v1/segments/{id}), delete, pause/resume (PUT /v1/segments/{id}/status), force a re-evaluation (POST /v1/segments/{id}/evaluate), and restore a version | No — operates on the definition. A write response carries a member count, not members. |
api.attributes.view | /v1/attributes, /v1/attributes/{id} | No — definitions only (name, type, enum values). The schema, not recipient data. |
api.attributes.manage | Create (POST /v1/attributes), edit (PUT /v1/attributes/{id}), delete (DELETE /v1/attributes/{id}), and preview (POST /v1/attributes/{id}/preview) attribute definitions | No — operates on the schema. The disruptive-edit impact payload returns affected Segment ids/names and a drop-out count, not recipients. |
api.attributes.view is the safe one to hand to an integration that only needs to know what fields exist (for example, to render a Segment-builder UI) without being able to read any recipient. api.attributes.manage, api.lists.manage and api.segments.manage are write scopes — grant them only to integrations that own the corresponding definitions, since a disruptive attribute edit can flip dependent Segments into a warning state, and a List delete leaves its membership log orphaned (see below).
Pagination
List and Segment collections (/v1/lists, /v1/segments, /v1/attributes) are bounded per org and returned newest-first. Member collections (/v1/lists/{id}/members, /v1/segments/{id}/members) can be large and are cursor-paginated — follow pagination.next_cursor until it’s null. Every collection response carries data plus a pagination object.
Lists
A List is a static membership set. Each member carries an added_at timestamp — when the contact was put on the List — its email, which is the contact’s current address (not the one it was added under), and its external_id, your own identifier for the contact (null when it has none).
{
"contact_id": "0192f1b7-...-...",
"email": "jane@example.com",
"external_id": "user_8812",
"added_at": "2026-06-10T14:22:00Z"
} If you keep a List in step with your own user table — adding users with PUT /v1/contacts/by-external-id/{external_id} and lists — find the members who are no longer users by comparing each member’s external_id with your user ids, not by email. A user who changed their address keeps their external_id, so an email diff would wrongly read them as a leaver plus a new user. A member whose external_id is null was added some other way; decide separately whether your sync owns it.
A List definition looks like this:
{
"id": "0192f1a3-...-...",
"name": "Beta cohort",
"key": "beta-cohort",
"description": "Hand-curated early-access users",
"source": "api",
"member_count": 412,
"status": "active",
"created_at": "2026-05-01T09:00:00Z",
"updated_at": "2026-06-10T14:22:00Z"
} key is an org-unique slug that stays stable across renames — prefer it over name when you reference a List from your own systems, and it is what {id} falls back to on a write when the value is not a UUID. source (api · ui · import · topic_export) records how the List was created — topic_export marks a snapshot List exported from a subscription topic, and import one applied from a bundle. status is active or archived; archiving never blocks membership or mailability.
`{id}` is a key or an id — everywhere
Wherever a List or Segment route takes {id}, it accepts the object’s org-unique key as readily as its UUID: GET /v1/lists/beta-cohort and PUT /v1/segments/gold-tier both work, on reads and writes alike. The same holds for {id} on workflows, audience_id on a broadcast, lists on a contact write, and ?channel= on messages and reports.
Prefer the key. It is immutable, it survives an export and re-import, and a reviewer reading your config six months from now can tell what it points at. A UUID tells them nothing.
The membership check takes a handle on both halves of its path — GET /v1/lists/eu-pro-users/members/jane%40example.com — where the contact may be its id, your own external_id, or its email address. Naming the contact by address there needs api.contacts.view in addition to api.lists.view, because turning an address into a contact is a contacts read; the id form does not.
Note what the two failures mean. A contact who exists but is not in the List is 200 with is_member: false; a handle that names nobody is 422. A typo’d address is therefore never reported as a confident “no”.
Before 14 September 2026 only the write routes resolved a key; reads took a UUID only, so PUT /v1/lists/beta-cohort succeeded while GET /v1/lists/beta-cohort returned 404. Both work now.
Managing List definitions
With the api.lists.manage scope you can author static-List definitions over the API — membership itself is managed separately, via lists on the contacts surface:
| Operation | Endpoint | Notes |
|---|---|---|
| Create | POST /v1/lists | Body: name (required), key (optional, defaults to a slug of name; immutable afterwards), description (optional). 201 with the new V1List. A key already in use is 409 conflict. |
| Edit | PUT /v1/lists/{id} | Body: name, description. key and source are immutable — a key in the body is ignored. |
| Delete | DELETE /v1/lists/{id} | 204 on success, idempotent-ish in that the membership log is left in place (harmless once the definition is gone). |
Segments
A Segment is defined by a SendQL predicate that resolves to a boolean per contact. The predicate references custom attributes as attr.<name> (the names come from /v1/attributes) plus engagement events and subscription state.
attr.plan = "pro" and attr.region = "eu" Over the API that predicate travels as a JSON string — the source field of a Segment, and the body of a preview.
The full language reference lives at SendLang
SendQL has its own home — sendlang.com/docs/sendql — where the complete grammar, every operator, and the parser are documented, alongside its companion language SendFlow. For how Segments behave in the product, see Segment syntax in the main docs.
A Segment’s response includes:
| Field | Meaning |
|---|---|
source | The SendQL expression that defines membership. |
profile_version | The version of the SendQL function/field profile the source was compiled against. |
eval_class | How membership is kept current: incremental (updated on events), sweep (recomputed on a schedule), or both. |
status | active, paused (not being re-evaluated), invalid (the predicate no longer compiles — e.g. an attribute it referenced was deleted), or archived (retired but preserved). |
eval_warning | A standing “evaluation disrupted” warning, present when a disruptive attribute edit has left the predicate referencing a changed or removed attribute. The Segment keeps evaluating best-effort until its predicate is re-saved. Absent when there’s no warning. |
eval_warning_at | When the standing eval_warning was raised; absent when none. |
member_count | Cached size, from the last evaluation. |
last_evaluated_at | Absent until the Segment has been evaluated at least once. |
content_hash | The optimistic-concurrency token over the author-editable definition. Send it back as expected_content_hash on the next edit. |
evaluation | Present only on a write response: complete (the save evaluated membership before returning, and member_count is fresh) or queued (the evaluation exceeded its budget, member_count is null, and a background reconcile took over). A null count is never a zero. |
requires | Everything the definition still depends on, with each dependency’s state. Present on write responses and on the detail read; absent on the collection read. |
Segment members carry entered_at — when the contact most recently entered the Segment (membership can churn as attributes change).
A Segment's count is eventually consistent
member_count and the members list reflect the last evaluation, not this instant. A Segment that’s paused or invalid stops being re-evaluated, so its membership freezes at the last good run. Check status and last_evaluated_at before treating a count as live.
Linting a predicate
POST /v1/segments/validate parses and analyses a candidate SendQL predicate against your attribute registry and returns positioned diagnostics plus the attributes the predicate reads. Nothing is persisted, no contacts are read, and no sample is returned — this is the cheap loop to run while you are still typing.
It always answers 200, including for a predicate that does not compile: valid is the answer and the diagnostics say where and why. The one 422 is a body with no source at all.
Previewing a predicate
POST /v1/segments/preview evaluates a candidate predicate against your current contacts without saving anything — the same call the dashboard’s predicate editor makes. It returns the match count, the inferred eval_class, and a small sample of matching contacts. An invalid predicate returns 422 validation_failed with the compile error. Run validate first for the diagnostics, preview next for the size, then commit with POST /v1/segments.
curl -s -X POST 'https://api.sendops.dev/v1/segments/preview' \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"source": "attr.plan = \"pro\" and attr.region = \"eu\""}' {
"count": 128,
"eval_class": "incremental",
"sample": [
{ "contact_id": "0192f1a3-...", "email": "jane@example.com" }
]
} Managing Segment definitions
With the api.segments.manage scope you can author Segments over the API.
| Operation | Endpoint | Notes |
|---|---|---|
| Create | POST /v1/segments | Body: name, source (the SendQL predicate), optional key and description. 201, and the predicate is evaluated before the response returns so the count is real. A key already in use is 409 conflict. |
| Edit | PUT /v1/segments/{id} | A merge: an omitted field keeps its stored value, so changing only source need not resend the name. key is immutable. Changing the predicate re-evaluates inline, exactly as create does; a byte-identical edit skips the evaluation entirely. |
| Delete | DELETE /v1/segments/{id} | 204. Refused 409 conflict while something live still depends on it — see below. |
| Pause / resume | PUT /v1/segments/{id}/status | paused stops reconciliation; active resumes it and evaluates inline. A Segment whose predicate is invalid cannot be activated (422) — fix the predicate first. |
| Re-evaluate | POST /v1/segments/{id}/evaluate | For when the data changed but the definition did not. Always 202: a full pass over the contact population is queued rather than held open, and the refreshed count arrives on the next GET. Only active Segments are evaluable; a short cooldown returns 429. |
A predicate that does not compile is still saved
Work in progress is not thrown away. A source that fails to compile is stored with status: "invalid" and the reason. What is rejected with 422 validation_failed is a predicate naming something your org does not have — positioned at the fault, with your real attribute names in candidates.
Concurrency. Send expected_content_hash (the content_hash from a previous read) on an edit and it becomes conditional: if the stored hash has moved on the edit is refused with 409 conflict and the response carries the current hash, so a retry is one re-read away. Omit it and there is no check.
Deleting is refused while something depends on it. A 409 names the dependents in referenced_by as {kind, key, name} — an active workflow whose trigger enrolls on this Segment, or a draft/scheduled broadcast that has it as an audience. Both would otherwise fail quietly and later. Repoint or remove the dependent, then retry.
Saving a Segment mails nobody: a broadcast or a workflow is what sends.
Attributes
The attribute registry is the schema Segment predicates compile against. Each definition has a lowercase name (referenced in SendQL as attr.<name>), a type (string · number · bool · datetime · enum), and, for enum, the allowed enum_values.
{
"id": "0192f1a3-...",
"name": "plan",
"type": "enum",
"enum_values": ["free", "pro", "enterprise"],
"description": "Billing plan",
"created_at": "2026-04-12T10:00:00Z",
"updated_at": "2026-04-12T10:00:00Z"
} Managing attribute definitions
With the api.attributes.manage scope you can author the attribute registry over the API — the same schema your Segments compile against:
| Operation | Endpoint | Returns |
|---|---|---|
| Create | POST /v1/attributes | 201 with the new V1Attribute. |
| Edit | PUT /v1/attributes/{id} | The updated definition, its mutation_class, and (for a disruptive edit) the committed impact. |
| Delete | DELETE /v1/attributes/{id} | The mutation_class (always disruptive) and impact. |
| Preview | POST /v1/attributes/{id}/preview | The projected mutation_class and impact — no writes. |
{id} on every attribute route takes the attribute’s name as readily as its UUID — the same name PUT /v1/attributes/by-name/{name} is keyed on and a predicate compiles against, so GET /v1/attributes/plan works. Activity properties follow the same rule with their dotted handle: GET /v1/activity-properties/order.total, split on the last dot, since an activity name may contain one and a property name may not.
The request body for create, edit, and preview is the same shape: name (lowercase identifier referenced as attr.<name>), type (string · number · bool · datetime · enum), enum_values (required when type is enum, rejected otherwise), and an optional description.
The registry is authoritative
An edit always applies — it is never blocked. What changes is the impact on the Segments that reference the attribute, reported as the mutation_class. Preview that impact first with POST /v1/attributes/{id}/preview (it performs no writes), then commit the same body with PUT.
Mutation classes and impact
Every edit, delete, and preview is classified by how it affects dependent Segment predicates:
mutation_class | Meaning |
|---|---|
free | No effect on any predicate — a description change, an enum widening, or moving between string and enum (which compile to the same cast). |
safe_but_stale | An enum value was removed. Predicates stay valid but may compare against a value that can no longer be written. |
disruptive | A rename or a cast-class change (e.g. string → number), or any delete. Segments that reference the attribute keep evaluating best-effort but carry a standing eval_warning until their predicate is re-saved. |
A disruptive edit (and every delete) returns an impact block:
| Field | Meaning |
|---|---|
affected_segments[] | Each Segment referencing the attribute — id, name, and typecheck_ok (whether its predicate still type-checks under the change), plus the warning the edit raises. |
projected_dropout | Org-wide count of contacts that drop out of the affected Segments under the change. 0 unless the change is disruptive. |
# Dry-run a type change before committing it (api.attributes.manage)
curl -s -X POST 'https://api.sendops.dev/v1/attributes/0192f1a3-.../preview' \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{"name": "plan", "type": "number"}' {
"mutation_class": "disruptive",
"impact": {
"affected_segments": [
{ "id": "0192f1a3-...", "name": "EU pro users", "typecheck_ok": false, "warning": "attribute \"plan\" changed type; predicate no longer type-checks" }
],
"projected_dropout": 128
}
} Version history
Every List, Segment and attribute keeps a version history, and every write snapshots the previous definition before it applies — so a bad edit is one call from being undone, and the snapshot records who made the change.
| Operation | Endpoint |
|---|---|
| List the versions | GET /v1/segments/{id}/versions · …/lists/{id}/versions · …/attributes/{id}/versions |
| Read one version | GET …/versions/{version} |
| Diff two versions | GET …/versions/{version}/diff |
| Restore a version | POST …/versions/{version}/restore |
The reads sit on the resource’s .view scope; the restore needs .manage. A restore is an ordinary write — it snapshots the state it replaces, so restoring twice leaves two rows in the history and the definition where you asked for it.
Keeping definitions in your own repository
Nothing here is owned by a repository, but plenty of teams want these definitions reviewed in one. That is what POST /v1/import is for: keep sendops.json and its files in your repo, and have CI push the bundle. GET /v1/export produces the same shape deterministically, so committing an export on a schedule does not generate a diff every night. Imports go through the same authoring services as the calls above — same validation, same version snapshots, same concurrency — and dry_run defaults to true, so a pull-request job plans without writing. See Push from CI.
Worked example: export a Segment’s members
Resolve the Segment by its stable key, then page its members to the end.
async function* segmentMembers(segmentId) {
let url = new URL(
`/v1/segments/${segmentId}/members?limit=200`,
"https://api.sendops.dev",
).toString();
while (url) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${process.env.SENDOPS_API_KEY}` },
});
if (!res.ok) throw new Error(`status ${res.status}`);
const body = await res.json();
for (const m of body.data) yield m;
url = body.pagination.next_cursor
? new URL(
`/v1/segments/${segmentId}/members?limit=200&cursor=${body.pagination.next_cursor}`,
"https://api.sendops.dev",
).toString()
: null;
}
}
for await (const m of segmentMembers("0192f1a3-...")) {
console.log(m.email, m.entered_at);
} This is for export and audit, not send-time gating
Reading a List or Segment tells you who’s a member right now. It does not enforce opt-outs — to respect subscription state at send time, send through SES SendEmail with ListManagementOptions. See Contacts & Topics.