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.
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.
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"
}*_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.
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
Adds a contact to your account. Email and phone_number are each unique per account; reusing one returns duplicate_contact (409).
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"] }'{
"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
Returns contacts newest-first (by created_at). Paginate with limit and offset.
curl "https://api.sendara.dev/v1/contacts?limit=2&offset=0" \
-H "Authorization: Bearer sk_live_xxx"{
"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
Fetches a single contact by its id. Returns not_found (404) if it doesn't exist.
curl https://api.sendara.dev/v1/contacts/ct_3f9a1c8e7b2d4a6f90e1c2b3a4d5e6f7 \
-H "Authorization: Bearer sk_live_xxx"{
"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
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.
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" }'{
"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
Permanently removes a contact and strips it from every static list. Returns not_found (404) if it's already gone.
curl -X DELETE https://api.sendara.dev/v1/contacts/ct_3f9a1c8e7b2d4a6f90e1c2b3a4d5e6f7 \
-H "Authorization: Bearer sk_live_xxx"{ "message": "Contact deleted" }Pagination
List endpoints (GET /v1/contacts and GET /v1/contacts/lists) page with two query parameters:
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))
doneImporting 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
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.
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" }'{
"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.
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
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.
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" }'{
"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
Returns your lists newest-first. Paginate with limit and offset.
curl "https://api.sendara.dev/v1/contacts/lists?limit=20" \
-H "Authorization: Bearer sk_live_xxx"{
"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
Fetches a list's metadata (including segment_rules for dynamic lists).
curl https://api.sendara.dev/v1/contacts/lists/list_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f \
-H "Authorization: Bearer sk_live_xxx"{
"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
Renames a list or rewrites its segment_rules. A list's type is fixed at creation and can't be changed.
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)" }'{
"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
Removes the list and its membership rows. The contacts themselves are untouched.
curl -X DELETE https://api.sendara.dev/v1/contacts/lists/list_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f \
-H "Authorization: Bearer sk_live_xxx"{ "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:
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" }
}
}'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:
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": … }(pluskeyfor attribute fields). It evaluates one contact attribute against one operator. - Group — a nested branch:
{ "match": "all" | "any", "conditions": [ … ] }. Amatchofalljoins its children with AND;anyjoins them with OR. Groups can hold leaves and further groups, so you can express arbitrary AND/OR nesting.
invalid_request (400).Fields and operators
Each leaf names a field and an op. The operators that are valid depend on the field:
| Field | Operators | Notes |
|---|---|---|
| email, phone, first_name, last_name | exists, not_exists, equals, not_equals, contains, not_contains | Text fields. equals / not_equals are case-insensitive; contains / not_contains are case-insensitive substring matches. exists also requires the value to be non-empty. |
| tag | contains, not_contains | Tests a single tag for membership in the contact's tag array. value is one tag string (e.g. { "field": "tag", "op": "contains", "value": "beta" }). |
| attribute | exists, not_exists, equals, not_equals, contains, gt, lt | Reads 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_consent | equals, not_equals, is_one_of | Matches 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_at | within_days, before, after | within_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"). |
| engagement | opened, not_opened, clicked, not_clicked | Tests 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. |
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
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).
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" }
]
}
}'{ "count": 1284 }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
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).
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" }'{
"id": "clm_a1b2c3d4e5f60718293a4b5c6d7e8f90",
"contact_list_id": "list_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f",
"contact_id": "ct_3f9a1c8e7b2d4a6f90e1c2b3a4d5e6f7",
"added_at": "2026-06-14T10:10:00Z"
}List 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.
curl https://api.sendara.dev/v1/contacts/lists/list_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f/members \
-H "Authorization: Bearer sk_live_xxx"{
"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
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.
curl -X DELETE \
https://api.sendara.dev/v1/contacts/lists/list_5c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f/members/ct_3f9a1c8e7b2d4a6f90e1c2b3a4d5e6f7 \
-H "Authorization: Bearer sk_live_xxx"{ "message": "Member removed" }Errors
Audience endpoints use the standard error envelope — { "error": { "code": "...", "message": "..." } }. The codes you'll see most often here:
| Code | Status | When it fires |
|---|---|---|
| duplicate_contact | 409 | Creating or updating a contact with an email or phone number already used in your account. |
| duplicate_member | 409 | Adding a contact that's already a member of the static list. |
| not_found | 404 | The contact, list, or member ID doesn't exist under your account. |
| invalid_request | 400 | Missing required field, bad import format, a dynamic list without segment_rules, or a membership call on a dynamic list. |
| unauthorized | 401 | Missing or invalid API key. |