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 (the front door today), an optional phone number for SMS, 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. Used by the SMS channel (next up).
device_tokenstring | nullOptional
Push device token, for the preview push channel.
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": "ada@acme.com",
  "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, OTPs) 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. Used by the SMS channel (next up).
device_tokenstring | nullOptional
Push device token, for the preview push channel.
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": "ada@acme.com", "first_name": "Ada", "tags": ["beta"] }'
Response · 201 Created
{
  "id": "ct_3f9a1c8e7b2d4a6f90e1c2b3a4d5e6f7",
  "account_id": "acct_8a21",
  "email": "ada@acme.com",
  "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": "ada@acme.com",
      "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": "ada@acme.com",
  "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": "ada@acme.com",
  "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. { tags, attributes } — see Dynamic lists 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 with two optional filters 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.

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": "ada@acme.com",
      "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.