Audiences

Contacts & lists

Store the people you send to, organize them into static and dynamic lists, and import them in bulk — the audience layer behind every send and broadcast.

A contact is a person you can reach: an email address, an optional phone number, plus the tags and attributes you use to segment them. A list is a named group of contacts — either a static set you curate by hand or a dynamic segment defined by rules. Broadcasts target lists, so contacts and lists are the foundation everything else builds on.

Every request is authenticated with an API key — Authorization: Bearer sk_live_… in production, or sk_test_… in the sandbox. All contact and list data is scoped to the account behind the key.

The contact object

Contacts are identified by an opaque ct_-prefixed ID. The identity fields (email, phone_number, device_token) are each unique per account, so the same email can't be stored twice. Everything else is free-form: use tags for coarse buckets and attributesfor structured data you'll filter or personalize on.

emailstring | nullOptional
Primary email address. Unique per account — a duplicate returns duplicate_contact (409). At least one of email or phone_number should be present.
phone_numberstring | nullOptional
E.164 phone number (e.g. +14155552671). Unique per account. Stored for future channels; email is the only send channel today.
device_tokenstring | nullOptional
Stored push device token. There is no push send channel today (email only).
first_namestringOptional
Given name. Defaults to an empty string.
last_namestringOptional
Family name. Defaults to an empty string.
tagsstring[]Optional
Freeform labels for segmentation. Dynamic lists match on tags. Defaults to an empty array.
attributesobjectOptional
Arbitrary JSON key–value pairs (plan, country, signup_source, …). Used for dynamic-list filtering and template variables. Defaults to {}.
email_consentenumOptional
Marketing consent for email: subscribed, unsubscribed, suppressed, or unknown. Defaults to unknown.
sms_consentenumOptional
Per-channel consent for SMS. Same enum as email_consent. Defaults to unknown.
push_consentenumOptional
Per-channel consent for push. Same enum as email_consent. Defaults to unknown.
voice_consentenumOptional
Per-channel consent for voice. Same enum as email_consent. Defaults to unknown.

A full contact looks like this:

{
  "id": "ct_3f9a1c8e7b2d4a6f90e1c2b3a4d5e6f7",
  "account_id": "acct_8a21",
  "email": "[email protected]",
  "phone_number": null,
  "device_token": null,
  "first_name": "Ada",
  "last_name": "Lovelace",
  "attributes": { "plan": "pro", "country": "GB" },
  "tags": ["beta", "founder"],
  "email_consent": "subscribed",
  "sms_consent": "unknown",
  "push_consent": "unknown",
  "voice_consent": "unknown",
  "created_at": "2026-06-14T10:00:00Z",
  "updated_at": "2026-06-14T10:00:00Z"
}
The four *_consent fields track marketing consent per channel independently — a contact can be subscribed for email while staying unknown for SMS. Transactional sends (receipts, password resets) ignore consent; marketing sends respect it.

Create a contact

Create a contact with a single call to POST /v1/contacts. Only the fields you care about are required — an email (or phone number) is enough.

Email and phone number are unique per account. Posting a contact whose email already exists returns duplicate_contact (409). To change an existing contact, use the update endpoint below or the bulk import, which upserts.

Contacts API

Full CRUD lives under /v1/contacts. Lists are returned newest-first and paginated with limit / offset.

Create a contact

POST/v1/contacts

Adds a contact to your account. Email and phone_number are each unique per account; reusing one returns duplicate_contact (409).

Body parameters
emailstring | nullOptional
Primary email address. Unique per account — a duplicate returns duplicate_contact (409). At least one of email or phone_number should be present.
phone_numberstring | nullOptional
E.164 phone number (e.g. +14155552671). Unique per account. Stored for future channels; email is the only send channel today.
device_tokenstring | nullOptional
Stored push device token. There is no push send channel today (email only).
first_namestringOptional
Given name. Defaults to an empty string.
last_namestringOptional
Family name. Defaults to an empty string.
tagsstring[]Optional
Freeform labels for segmentation. Dynamic lists match on tags. Defaults to an empty array.
attributesobjectOptional
Arbitrary JSON key–value pairs (plan, country, signup_source, …). Used for dynamic-list filtering and template variables. Defaults to {}.
email_consentenumOptional
Marketing consent for email: subscribed, unsubscribed, suppressed, or unknown. Defaults to unknown.
sms_consentenumOptional
Per-channel consent for SMS. Same enum as email_consent. Defaults to unknown.
push_consentenumOptional
Per-channel consent for push. Same enum as email_consent. Defaults to unknown.
voice_consentenumOptional
Per-channel consent for voice. Same enum as email_consent. Defaults to unknown.
Example request
curl https://api.sendara.dev/v1/contacts \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "email": "[email protected]", "first_name": "Ada", "tags": ["beta"] }'
Response · 201 Created
{
  "id": "ct_3f9a1c8e7b2d4a6f90e1c2b3a4d5e6f7",
  "account_id": "acct_8a21",
  "email": "[email protected]",
  "phone_number": null,
  "device_token": null,
  "first_name": "Ada",
  "last_name": "Lovelace",
  "attributes": { "plan": "pro", "country": "GB" },
  "tags": ["beta", "founder"],
  "email_consent": "subscribed",
  "sms_consent": "unknown",
  "push_consent": "unknown",
  "voice_consent": "unknown",
  "created_at": "2026-06-14T10:00:00Z",
  "updated_at": "2026-06-14T10:00:00Z"
}

List contacts

GET/v1/contacts

Returns contacts newest-first (by created_at). Paginate with limit and offset.

Query parameters
limitintegerOptional
Page size. Defaults to 50.
offsetintegerOptional
Number of contacts to skip. Defaults to 0.
Example request
curl "https://api.sendara.dev/v1/contacts?limit=2&offset=0" \
  -H "Authorization: Bearer sk_live_xxx"
Response · 200 OK
{
  "contacts": [
    {
      "id": "ct_3f9a1c8e7b2d4a6f90e1c2b3a4d5e6f7",
      "email": "[email protected]",
      "first_name": "Ada",
      "last_name": "Lovelace",
      "tags": ["beta", "founder"],
      "attributes": { "plan": "pro" },
      "email_consent": "subscribed",
      "created_at": "2026-06-14T10:00:00Z",
      "updated_at": "2026-06-14T10:00:00Z"
    }
  ]
}

Retrieve a contact

GET/v1/contacts/{id}

Fetches a single contact by its id. Returns not_found (404) if it doesn't exist.

Example request
curl https://api.sendara.dev/v1/contacts/ct_3f9a1c8e7b2d4a6f90e1c2b3a4d5e6f7 \
  -H "Authorization: Bearer sk_live_xxx"
Response · 200 OK
{
  "id": "ct_3f9a1c8e7b2d4a6f90e1c2b3a4d5e6f7",
  "account_id": "acct_8a21",
  "email": "[email protected]",
  "phone_number": null,
  "device_token": null,
  "first_name": "Ada",
  "last_name": "Lovelace",
  "attributes": { "plan": "pro", "country": "GB" },
  "tags": ["beta", "founder"],
  "email_consent": "subscribed",
  "sms_consent": "unknown",
  "push_consent": "unknown",
  "voice_consent": "unknown",
  "created_at": "2026-06-14T10:00:00Z",
  "updated_at": "2026-06-14T10:00:00Z"
}

Update a contact

PUT/v1/contacts/{id}

Updates the supplied fields and leaves the rest untouched — only the keys you send are written. Pass attributes or tags to replace those collections wholesale.

Body parameters
emailstring | nullOptional
Send null to clear the email, or a new value to change it. Omit to leave unchanged.
first_namestringOptional
Omit to leave unchanged.
last_namestringOptional
Omit to leave unchanged.
tagsstring[]Optional
Replaces the full tag set when present.
attributesobjectOptional
Replaces the full attributes object when present.
email_consentenumOptional
Update marketing consent for any channel: *_consent fields accept the same enum.
Example request
curl -X PUT https://api.sendara.dev/v1/contacts/ct_3f9a1c8e7b2d4a6f90e1c2b3a4d5e6f7 \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "tags": ["beta", "founder", "paid"], "email_consent": "unsubscribed" }'
Response · 200 OK
{
  "id": "ct_3f9a1c8e7b2d4a6f90e1c2b3a4d5e6f7",
  "account_id": "acct_8a21",
  "email": "[email protected]",
  "phone_number": null,
  "device_token": null,
  "first_name": "Ada",
  "last_name": "Lovelace",
  "attributes": { "plan": "pro", "country": "GB" },
  "tags": ["beta", "founder"],
  "email_consent": "subscribed",
  "sms_consent": "unknown",
  "push_consent": "unknown",
  "voice_consent": "unknown",
  "created_at": "2026-06-14T10:00:00Z",
  "updated_at": "2026-06-14T10:00:00Z"
}

Delete a contact

DELETE/v1/contacts/{id}

Permanently removes a contact and strips it from every static list. Returns not_found (404) if it's already gone.

Example request
curl -X DELETE https://api.sendara.dev/v1/contacts/ct_3f9a1c8e7b2d4a6f90e1c2b3a4d5e6f7 \
  -H "Authorization: Bearer sk_live_xxx"
Response · 200 OK
{ "message": "Contact deleted" }

Pagination

List endpoints (GET /v1/contacts and GET /v1/contacts/lists) page with two query parameters:

limitintegerOptional
Maximum rows to return. Defaults to 50.
offsetintegerOptional
Number of rows to skip from the start of the result set. Defaults to 0.

Walk a full audience by incrementing offset by limit until a page comes back shorter than limit — that page is the last one. Results are ordered by created_at descending, so the newest contacts appear first.

# Page through every contact, 100 at a time
offset=0
while :; do
  page=$(curl -s "https://api.sendara.dev/v1/contacts?limit=100&offset=$offset" \
    -H "Authorization: Bearer sk_live_xxx")
  count=$(echo "$page" | jq '.contacts | length')
  echo "$page" | jq -c '.contacts[]'
  [ "$count" -lt 100 ] && break
  offset=$((offset + 100))
done

Importing contacts

To load a large audience at once, stage a CSV or JSON file in object storage and call POST /v1/contacts/import with its key. The import is an upsert: a row whose email or phone number already exists updates that contact instead of failing, so re-running the same file is safe.

Each row is validated on its own. A row needs at least an email or a phone_number; emails must parse and phone numbers must be E.164. Invalid rows are skipped and reported in the response — the valid rows still import.

Import contacts in bulk

POST/v1/contacts/import

Imports contacts from a CSV or JSON file you've staged in object storage. Each row is validated independently; valid rows are imported even when others fail, and a contact whose email or phone already exists is updated rather than duplicated.

Body parameters
s3_keystringRequired
Object key of the staged import file in your account's import bucket.
formatenumRequired
csv or json. Anything else returns invalid_request (400).
Example request
curl https://api.sendara.dev/v1/contacts/import \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "s3_key": "imports/2026-06/launch-list.csv", "format": "csv" }'
Response · 200 OK
{
  "success_count": 1840,
  "error_count": 2,
  "errors": [
    { "row": 17, "message": "row must have at least an email or phone_number" },
    { "row": 209, "message": "invalid phone_number \"5551234\": must be E.164 format" }
  ]
}

A CSV import reads a header row and recognizes the columns email, phone_number, first_name, last_name, and tags (comma-separated within a quoted cell). A JSON import is an array of objects with the same keys plus an attributes object.

The response reports a success_count, an error_count, and a per-row errors array — each entry carries the 1-based row number and a message. Inspect it to see exactly which rows were rejected and why.

Lists

A list groups contacts under a name. There are two kinds, and a list's kind is fixed when you create it:

  • Static — an explicit membership you manage by adding and removing contacts. Use these for hand-picked groups like a beta cohort or a VIP list.
  • Dynamic — a live segment defined by segment_rules. There's no stored membership; instead the rules are evaluated against your contacts every time you read the members, so the list always reflects current data.

Lists are identified by a list_-prefixed ID.

Lists API

Create a list

POST/v1/contacts/lists

Creates a static or dynamic list. Static lists hold an explicit membership you manage by hand; dynamic lists are defined by segment_rules and re-evaluated every time you read them.

Body parameters
namestringRequired
Human-readable list name.
list_typeenumOptional
static (default) or dynamic.
segment_rulesobjectOptional
Required for dynamic lists, ignored for static. Holds the legacy { tags, attributes } shortcuts and/or a rich { match, conditions } rule tree — see Dynamic lists and Segments below.
Example request
curl https://api.sendara.dev/v1/contacts/lists \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Beta founders", "list_type": "static" }'
Response · 201 Created
{
  "id": "list_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
  "account_id": "acct_8a21",
  "name": "Beta founders",
  "list_type": "static",
  "created_at": "2026-06-14T10:00:00Z",
  "updated_at": "2026-06-14T10:00:00Z"
}

List lists

GET/v1/contacts/lists

Returns your lists newest-first. Paginate with limit and offset.

Query parameters
limitintegerOptional
Page size. Defaults to 50.
offsetintegerOptional
Number of lists to skip. Defaults to 0.
Example request
curl "https://api.sendara.dev/v1/contacts/lists?limit=20" \
  -H "Authorization: Bearer sk_live_xxx"
Response · 200 OK
{
  "lists": [
    {
      "id": "list_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
      "name": "Beta founders",
      "list_type": "static",
      "created_at": "2026-06-14T10:00:00Z",
      "updated_at": "2026-06-14T10:00:00Z"
    }
  ]
}

Retrieve a list

GET/v1/contacts/lists/{id}

Fetches a list's metadata (including segment_rules for dynamic lists).

Example request
curl https://api.sendara.dev/v1/contacts/lists/list_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f \
  -H "Authorization: Bearer sk_live_xxx"
Response · 200 OK
{
  "id": "list_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
  "name": "Beta founders",
  "list_type": "static",
  "created_at": "2026-06-14T10:00:00Z",
  "updated_at": "2026-06-14T10:00:00Z"
}

Update a list

PUT/v1/contacts/lists/{id}

Renames a list or rewrites its segment_rules. A list's type is fixed at creation and can't be changed.

Body parameters
namestringOptional
New name. Omit to leave unchanged.
segment_rulesobjectOptional
New rules for a dynamic list. Omit to leave unchanged.
Example request
curl -X PUT https://api.sendara.dev/v1/contacts/lists/list_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "name": "Beta founders (2026)" }'
Response · 200 OK
{
  "id": "list_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
  "name": "Beta founders (2026)",
  "list_type": "static",
  "created_at": "2026-06-14T10:00:00Z",
  "updated_at": "2026-06-14T10:05:00Z"
}

Delete a list

DELETE/v1/contacts/lists/{id}

Removes the list and its membership rows. The contacts themselves are untouched.

Example request
curl -X DELETE https://api.sendara.dev/v1/contacts/lists/list_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f \
  -H "Authorization: Bearer sk_live_xxx"
Response · 200 OK
{ "message": "Contact list deleted" }

Dynamic lists

A dynamic list carries a segment_rules object. The simplest form is two legacy shortcuts that combine with AND logic:

tagsstring[]Optional
A contact must have ALL of these tags to match (array containment).
attributesobjectOptional
A contact must match ALL of these attribute key–value pairs to match (JSON containment).

For example, this list captures every paid contact in Great Britain who is tagged beta:

curl https://api.sendara.dev/v1/contacts/lists \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Paid GB betas",
    "list_type": "dynamic",
    "segment_rules": {
      "tags": ["beta"],
      "attributes": { "plan": "pro", "country": "GB" }
    }
  }'
A dynamic list without segment_rules returns invalid_request (400). Reading GET …/memberson a dynamic list evaluates the rules live — there's nothing to add or remove by hand.

The tags and attributesshortcuts only do equality and “has all of” matching. For anything richer — OR logic, consent, recency, numeric comparisons, or engagement — add a match / conditions rule tree, covered in Segments below. The legacy shortcuts and the rule tree may both be present; they are combined with AND.

Segments

A segment is the rich form of segment_rules: a tree of conditions that compiles down to a single contacts query. The same object powers dynamic-list membership and the live size preview, and you can hand it to a broadcast as an ad-hoc audience. The whole tree lives in two keys on segment_rules:

matchenumOptional
How the top-level conditions combine: "all" (AND, the default) or "any" (OR).
conditionsarrayOptional
The rule tree. Each element is either a leaf ({ field, op, value }) or a nested group ({ match, conditions }). Groups can nest up to 5 levels deep, with at most 100 leaf conditions in total.
tagsstring[]Optional
Legacy shortcut: a contact must have ALL of these tags (array containment). Combined with conditions using AND.
attributesobjectOptional
Legacy shortcut: a contact must match ALL of these attribute key–value pairs (JSON containment). Combined with conditions using AND.

The condition tree

Every entry in conditions is a node, and a node is one of two shapes:

  • Leaf — a single test: { "field": …, "op": …, "value": … } (plus key for attribute fields). It evaluates one contact attribute against one operator.
  • Group — a nested branch: { "match": "all" | "any", "conditions": [ … ] }. A match of all joins its children with AND; any joins them with OR. Groups can hold leaves and further groups, so you can express arbitrary AND/OR nesting.
matchenumOptional
Group node only. "all" (AND) or "any" (OR) for the child conditions. Defaults to "all".
conditionsarrayOptional
Group node only. The child conditions, each itself a leaf or a nested group.
fieldenumOptional
Leaf node only. The contact attribute to test — see the field table below.
openumOptional
Leaf node only. The comparison operator. Which operators are valid depends on the field.
keystringOptional
Leaf node only. Required when field is "attribute" — the attributes JSON key to read (e.g. "plan").
valuestring | number | string[]Optional
Leaf node only. The right-hand value. Omitted for exists / not_exists, opened / clicked (where it is an optional look-back window in days), and required otherwise.
The tree may nest up to 5 levels deep and hold at most 100 leaf conditionsin total. Exceeding either limit, or using an unknown field or an operator the field doesn't support, returns invalid_request (400).

Fields and operators

Each leaf names a field and an op. The operators that are valid depend on the field:

Segment fields and their operators
FieldOperatorsNotes
email, phone, first_name, last_nameexists, not_exists, equals, not_equals, contains, not_containsText fields. equals / not_equals are case-insensitive; contains / not_contains are case-insensitive substring matches. exists also requires the value to be non-empty.
tagcontains, not_containsTests a single tag for membership in the contact's tag array. value is one tag string (e.g. { "field": "tag", "op": "contains", "value": "beta" }).
attributeexists, not_exists, equals, not_equals, contains, gt, ltReads attributes[key] — key is required. equals / not_equals / contains compare as text; gt / lt compare numerically (non-numeric values never match).
email_consent, sms_consent, push_consent, voice_consentequals, not_equals, is_one_ofMatches the per-channel consent enum (subscribed, unsubscribed, suppressed, unknown). is_one_of takes an array, e.g. { "op": "is_one_of", "value": ["subscribed", "unknown"] }.
created_atwithin_days, before, afterwithin_days takes a positive integer (added in the last N days); before / after take an ISO-8601 timestamp string (e.g. "2026-01-01T00:00:00Z").
engagementopened, not_opened, clicked, not_clickedTests the contact's email message history (message_events). value is an optional positive integer — a look-back window in days. Omit it to consider all time.
The value is omitted for exists / not_exists. For the engagement operators it is optional: a positive integer there narrows the test to a look-back window in days, and leaving it off considers all time. Everywhere else value is required, and attribute leaves additionally require a key.

Engagement conditions

The engagement field tests a contact against their email message history— the open and click events recorded on the messages you've sent them. Four operators are available:

  • opened / clicked — the contact has at least one matching event.
  • not_opened / not_clicked — the contact has no matching event (and has an email on file).

Pass an optional positive integer as the valueto scope the test to the last N days; omit it to consider the contact's entire history. Matching is keyed on the contact's email, so engagement only applies to contacts that have one.

{ "field": "engagement", "op": "not_opened", "value": 60 }

This leaf matches contacts who have not opened any email from you in the last 60 days — a re-engagement audience in one line.

A full segment

Putting the pieces together, this dynamic list captures beta-tagged, email-subscribed contacts in either GB or IE, whose mrr attribute is above 50, who were created in the last 90 days and have opened an email in the last 30:

{
  "name": "Active paid GB betas",
  "list_type": "dynamic",
  "segment_rules": {
    "match": "all",
    "conditions": [
      { "field": "tag", "op": "contains", "value": "beta" },
      { "field": "email_consent", "op": "equals", "value": "subscribed" },
      {
        "match": "any",
        "conditions": [
          { "field": "attribute", "op": "equals", "key": "country", "value": "GB" },
          { "field": "attribute", "op": "equals", "key": "country", "value": "IE" }
        ]
      },
      { "field": "attribute", "op": "gt", "key": "mrr", "value": 50 },
      { "field": "created_at", "op": "within_days", "value": 90 },
      { "field": "engagement", "op": "opened", "value": 30 }
    ]
  }
}

Note the nested group: the top level is all (AND), but the two country checks sit inside an any (OR) group, so a contact in either country qualifies while every other condition must still hold.

Previewing a segment

Before saving a segment as a list — or while someone is still building it in a UI — call POST /v1/contacts/segments/preview to count how many contacts match, right now, without creating anything. It takes the same segment_rules object and returns a single count, so you can show a live audience size as the rules change.

Preview a segment's size

POST/v1/contacts/segments/preview

Counts how many of your contacts match a segment_rules object — without saving a list. Use it to size an audience live as someone builds a segment, before creating a dynamic list or using the rules as a broadcast audience. The same rules are validated and compiled exactly as they would be for a dynamic list, so invalid fields or operators return invalid_request (400).

Body parameters
segment_rulesobjectRequired
The rule object to evaluate — the same { match, conditions, tags, attributes } shape a dynamic list carries.
Example request
curl https://api.sendara.dev/v1/contacts/segments/preview \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{
    "segment_rules": {
      "match": "all",
      "conditions": [
        { "field": "tag", "op": "contains", "value": "beta" },
        { "field": "email_consent", "op": "equals", "value": "subscribed" }
      ]
    }
  }'
Response · 200 OK
{ "count": 1284 }
The preview validates and compiles the rules exactly as a dynamic list would, so a bad field, an unsupported operator, or a missing key on an attribute leaf returns invalid_request(400) — the same error you'd get saving them to a list.

Managing membership

Membership endpoints apply to static lists only. Add a contact with POST …/members, list the current members with GET …/members, and remove one with DELETE …/members/{contactId}. Calling an add or remove on a dynamic list returns invalid_request (400), since its membership comes from rules.

Add a member

POST/v1/contacts/lists/{id}/members

Adds a contact to a static list. Adding to a dynamic list returns invalid_request (400) — dynamic membership is derived from rules, not set by hand. Adding the same contact twice returns duplicate_member (409).

Body parameters
contact_idstringRequired
ID of the contact to add (e.g. ct_…).
Example request
curl https://api.sendara.dev/v1/contacts/lists/list_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f/members \
  -H "Authorization: Bearer sk_live_xxx" \
  -H "Content-Type: application/json" \
  -d '{ "contact_id": "ct_3f9a1c8e7b2d4a6f90e1c2b3a4d5e6f7" }'
Response · 201 Created
{
  "id": "clm_a1b2c3d4e5f60718293a4b5c6d7e8f90",
  "contact_list_id": "list_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
  "contact_id": "ct_3f9a1c8e7b2d4a6f90e1c2b3a4d5e6f7",
  "added_at": "2026-06-14T10:10:00Z"
}

List members

GET/v1/contacts/lists/{id}/members

Returns the contacts in a list. For static lists, that's the explicit membership; for dynamic lists, the segment_rules are evaluated against your contacts on the fly and the matches are returned.

Example request
curl https://api.sendara.dev/v1/contacts/lists/list_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f/members \
  -H "Authorization: Bearer sk_live_xxx"
Response · 200 OK
{
  "members": [
    {
      "id": "ct_3f9a1c8e7b2d4a6f90e1c2b3a4d5e6f7",
      "email": "[email protected]",
      "first_name": "Ada",
      "tags": ["beta", "founder"],
      "attributes": { "plan": "pro" },
      "email_consent": "subscribed",
      "created_at": "2026-06-14T10:00:00Z",
      "updated_at": "2026-06-14T10:00:00Z"
    }
  ]
}

Remove a member

DELETE/v1/contacts/lists/{id}/members/{contactId}

Removes a contact from a static list. Returns not_found (404) if the contact isn't a member, and invalid_request (400) on a dynamic list.

Example request
curl -X DELETE \
  https://api.sendara.dev/v1/contacts/lists/list_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f/members/ct_3f9a1c8e7b2d4a6f90e1c2b3a4d5e6f7 \
  -H "Authorization: Bearer sk_live_xxx"
Response · 200 OK
{ "message": "Member removed" }

Errors

Audience endpoints use the standard error envelope — { "error": { "code": "...", "message": "..." } }. The codes you'll see most often here:

Audience error codes
CodeStatusWhen it fires
duplicate_contact409Creating or updating a contact with an email or phone number already used in your account.
duplicate_member409Adding a contact that's already a member of the static list.
not_found404The contact, list, or member ID doesn't exist under your account.
invalid_request400Missing required field, bad import format, a dynamic list without segment_rules, or a membership call on a dynamic list.
unauthorized401Missing or invalid API key.