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.
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": "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"
}*_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.
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": "ada@acme.com", "first_name": "Ada", "tags": ["beta"] }'{
"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
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": "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
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": "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
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": "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
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 with two optional filters 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.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": "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
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. |