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>.
Segments are read-only in the Public API — every endpoint is a GET, with one exception: POST /v1/segments/preview is a non-mutating dry-run that evaluates a candidate predicate without saving anything. Segments are created and maintained from the dashboard or as code in a connected git repository (see Git-backed definitions below).
Lists are different: List definitions are writable through the API with the api.lists.manage scope — create, edit, and delete — while membership stays on the contacts surface (lists on /v1/contacts). See Managing List definitions.
Attributes are also writable. Beyond the read endpoints, you can create, edit, delete, and dry-run-preview attribute definitions with the api.attributes.manage scope — see Managing attribute definitions. Editing the schema can affect the Segments that reference it, so every write reports its mutation class.
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 |
| A single Segment’s detail page | GET /v1/segments/{id} · GET /v1/segments/{id}/members |
| The Segment predicate editor’s live preview | POST /v1/segments/preview |
| Custom attribute definitions | GET /v1/attributes |
Scopes
Five 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, and /v1/segments/preview | Yes — returns member contact ids/emails plus the predicate itself. |
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 and api.lists.manage are write scopes — grant them only to integrations that own your attribute schema or your List definitions respectively, 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.
{
"id": "0192f1a3-...-...",
"name": "Beta cohort",
"key": "beta-cohort",
"description": "Hand-curated early-access users",
"source": "api",
"member_count": 412,
"origin": "managed",
"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. source (api · ui · import · topic_export) records how the List was created — topic_export marks a snapshot List exported from a subscription topic. origin (managed · git) and status (active · archived) follow the same git-backed provenance model as Segments and attributes; a git-backed List also carries source_path.
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 — including one owned by a git-backed List — is 409 conflict. |
| Edit | PUT /v1/lists/{id} | Body: name, description. {id} resolves as a UUID or, failing that, the List’s key. 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). |
Git-backed Lists are read-only here
A List whose origin is git can’t be edited or deleted through the API — PUT/DELETE /v1/lists/{id} return 409 conflict, the same “can’t mutate what git owns” rule Segments and attributes follow. Change it in the connected repository (the lists key in your manifest) instead; SendOps reconciles it on push, and removing the key from the repo archives (never deletes) the List. See Git-backed Lists, Segments, and attributes.
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. 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, invalid (the predicate no longer compiles), or archived (a git-backed Segment whose source file was removed). |
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. |
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.
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, so you can validate before persisting a Segment elsewhere.
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" }
]
} 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. |
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
}
} Git-backed definitions are read-only here
An attribute whose origin is git can’t be edited or deleted through the API — these calls return 409 conflict. Change it in the connected repository instead; SendOps reconciles it on push. See Git-backed Lists, Segments, and attributes.
Git-backed Lists, Segments, and attributes
Lists, Segments, and attribute definitions can all be managed as code in a connected GitHub repository (a List via the manifest’s lists key, a Segment via its own file, an attribute via the attribute schema). When a definition originates from git, its response carries:
| Field | Meaning |
|---|---|
origin | managed (created in the dashboard / via the session API) or git (defined in the connected repo). |
source_path | The repo-relative file the definition is defined in (git-backed only). |
last_synced_at | When SendOps last reconciled it from git (git-backed only). Segments and attributes only — a List reports git-backed provenance via origin/source_path/status without a last_synced_at field. |
Edit git-backed definitions in the repository — SendOps validates and reconciles them on push, and a removed source file moves the definition to archived (a List’s key, a Segment’s or attribute’s source file). These fields are simply absent for dashboard-managed definitions.
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.