Contacts & Topics
The contacts API exposes the same SES contact list your dashboard shows: the list metadata, its subscription topics, and the individual contacts with their attributes and per-topic preferences. You can read the whole list and write individual contacts — create, replace, patch, and delete — plus manage static-List membership inline and import contacts in bulk. SendOps keeps these records in sync with Amazon SES; the API never sends on your behalf.
SES owns the contact list
Your contact list lives in Amazon SES (one list per account). SendOps mirrors it via a periodic sync, so contacts you add in SES appear here automatically, and subscription state stays authoritative in SES. Writes through this API are applied to SES directly (write-through) and then reflected back. To honour opt-outs at send time, keep using SendEmail with ListManagementOptions — that’s what makes SES record subscriptions and inject the unsubscribe URL.
How it maps to the dashboard
| Dashboard | API |
|---|---|
| Contact Lists → list header (name, counts, last sync) | GET /v1/contact-list |
| Contact Lists → Topics tab | GET /v1/topics |
| Contact Lists → Contacts tab (search/filter) | GET /v1/contacts |
| A single contact’s detail page | GET /v1/contacts/{email} |
| Add a contact | POST /v1/contacts |
| Edit a contact (full replace) | PUT /v1/contacts/{email} |
| Edit a contact (partial) | PATCH /v1/contacts/{email} |
| Delete a contact | DELETE /v1/contacts/{email} |
| Import contacts (CSV/bulk) | POST /v1/contacts/bulk → GET /v1/contacts/bulk/{job_id} |
Scopes
Four scopes gate this surface. api.topics.view and api.contacts.view are granted by the dashboard contacts.view permission; api.topics.manage and api.contacts.manage are granted by contacts.manage:
| Scope | Grants | PII / risk |
|---|---|---|
api.topics.view | /v1/topics, /v1/contact-list | List-level metadata and the topic taxonomy — no recipient PII. |
api.topics.manage | POST /v1/topics, PUT/DELETE /v1/topics/{name} | Write scope — creates, edits, and deletes topic definitions. Definition-only: no recipient PII. Git-backed topics are read-only here (409 conflict) — edit them in the connected repository. |
api.contacts.view | GET /v1/contacts, GET /v1/contacts/{email} | Recipient emails, attributes, and preferences — PII. Grant deliberately. |
api.contacts.manage | POST/PUT/PATCH/DELETE /v1/contacts, POST /v1/contacts/bulk, PUT /v1/contacts/by-external-id/{external_id} | Write scope — creates, edits, and deletes recipients and changes their subscription state. Grant only to integrations that own your contact roster. |
Splitting view from manage means an integration that only renders a topic menu can hold api.topics.view without reading any email, and a reporting tool can hold api.contacts.view without being able to mutate the roster. Contact emails are returned in full under api.contacts.view — the scope itself is the PII gate, since the list is operator-managed by definition. Note that reading a topic and defining one are now split the same way contacts are: api.topics.view never lets a key create, rename, or remove a topic — that requires api.topics.manage. Setting a contact’s own preference for a topic is a contact write (api.contacts.manage), not a topic write.
Idempotent writes
Every write endpoint (POST/PUT/PATCH/DELETE /v1/contacts and POST /v1/contacts/bulk) accepts an optional Idempotency-Key header. Send a unique key per logical operation (a UUID is ideal); if you retry with the same key within 24 hours, SendOps replays the original response — status code, body, and all — instead of applying the write twice.
curl -X POST 'https://api.sendops.dev/v1/contacts' \
-H "Authorization: Bearer sk_live_..." \
-H "Idempotency-Key: 7c5b6a1e-2f8d-4a3b-9e1c-0d2f4a6b8c0e" \
-H "Content-Type: application/json" \
-d '{"email":"jane@example.com","attributes":{"plan":"pro"}}' The key is scoped to your org and the exact endpoint. Reusing a key with a different body is rejected with 409 conflict (“this Idempotency-Key was already used with a different request”), not silently replayed — pick a fresh key whenever the payload changes.
Writing a contact
There are three single-contact write verbs, each mapping to a different merge semantics:
| Verb | Semantics | Missing fields |
|---|---|---|
POST /v1/contacts | Create only. 409 conflict if an active contact already exists (an archived one is reactivated, returns 201). | Default (no attributes, list-default topics). |
PUT /v1/contacts/{email} | Create-or-replace. 201 on create, 200 on update. | Cleared — an omitted attributes wipes all attributes, omitted topic_preferences revert to the list default. |
PATCH /v1/contacts/{email} | Partial diff-merge. 404 if the contact doesn’t exist. | Left as-is. A null attribute value clears just that key; listed topics are merged in. |
DELETE /v1/contacts/{email} archives the contact in SES and is idempotent — deleting an unknown or already-archived contact returns 204.
Attribute validation
Attribute values you write are validated and coerced against your org’s attribute registry (the same schema your Segments compile against). An unknown attribute key or a type mismatch returns 422 attribute_validation_failed with a per-attribute errors[] array — fix the offending value and retry. (In bulk import the same failure only marks that one row failed; see below.)
Upsert by external id
PUT /v1/contacts/by-external-id/{external_id} is a fourth write path, keyed by a caller-supplied stable external_id rather than an email address. It’s the primitive to reach for when you’re syncing your own system’s state into SendOps on a schedule — a nightly job, a CDC stream, a CRM webhook — rather than reacting to a single user action.
The distinction that matters: this is not the 24-hour Idempotency-Key header mechanism used elsewhere on this surface. external_id is a permanent unique constraint on (org, external_id). Calling this endpoint repeatedly with the same external_id always converges on the same contact — safe to re-run forever, not just within a retry window. If the external id already exists, its email can be changed by supplying a different email in the body.
Body fields:
| Field | Meaning |
|---|---|
email | The contact’s address. Required when creating; optional (but honoured) when updating. |
attributes | Desired attribute map, validated against the registry. |
unsubscribe_all | Master opt-out. Omit (or send null) to leave the contact’s current opt-out state untouched. |
topic_preferences | Per-topic subscription preferences. |
lists | Static-List keys/ids. Unlike the directive lists on POST/PATCH, this replaces membership with exactly this set — it is not additive. A present empty array clears all memberships; omitting lists leaves membership untouched. |
Scope: api.contacts.manage — the same scope as every other contact write, no new scope required. Returns 201 when the external id creates a new contact, 200 when it updates an existing one.
curl -X PUT 'https://api.sendops.dev/v1/contacts/by-external-id/cust_9182' \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"email": "jane@example.com",
"attributes": { "plan": "pro" },
"lists": ["beta-cohort"]
}' Inline static-List membership
Contact writes can manage static-List membership in the same call, via a lists field, so you don’t need a second round-trip:
- On
POSTandPATCH,listsis a directive object:addandremoveapply a delta, orsetreplaces membership declaratively (an emptysetclears every membership).add/removeandsetare mutually exclusive. - On
PUT,listsis a plain array = the exact membership. A present empty array clears all memberships; omittinglists(or sendingnull) leaves membership untouched.
List ids must name static Lists in your org (dynamic Segments compute membership and can’t be written to). The write response echoes the applied membership back in lists so you can confirm the result.
# Create a contact and add it to two static lists in one call
curl -X POST 'https://api.sendops.dev/v1/contacts' \
-H "Authorization: Bearer sk_live_..." \
-H "Content-Type: application/json" \
-d '{
"email": "jane@example.com",
"attributes": { "plan": "pro" },
"lists": { "add": ["0192f3a1-...","0192f3b2-..."] }
}' Bulk import
To import many contacts at once, POST /v1/contacts/bulk with up to 5000 contacts. Each row has the same shape as a POST body (email, attributes, topic preferences, inline lists), but bulk import upserts — it creates, reactivates an archived contact, or updates an existing one, and never returns 409.
The endpoint is asynchronous: it returns 202 with a job_id, and a background worker processes the batch. Poll GET /v1/contacts/bulk/{job_id} until status is completed or failed, then read the per-contact outcomes.
Per-row failures don’t reject the whole batch — unlike single-contact writes, an invalid row (e.g. a failed attribute validation) becomes a failed item with a reason while the rest of the batch proceeds. The results page is keyset-paginated by item index; follow next_cursor to read every outcome.
# Enqueue a bulk import
curl -X POST 'https://api.sendops.dev/v1/contacts/bulk' \
-H "Authorization: Bearer sk_live_..." \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
-d '{
"contacts": [
{ "email": "a@example.com", "attributes": { "plan": "pro" } },
{ "email": "b@example.com", "unsubscribe_all": true }
]
}'
# → 202 { "job_id": "0192...", "status": "pending", "total": 2 }
# Poll for status + per-row outcomes
curl 'https://api.sendops.dev/v1/contacts/bulk/0192...' \
-H "Authorization: Bearer sk_live_..." {
"job_id": "0192f3c4-...",
"status": "completed",
"total": 2,
"processed": 2,
"succeeded": 2,
"failed": 0,
"results": {
"data": [
{ "index": 0, "email": "a@example.com", "outcome": "created" },
{ "index": 1, "email": "b@example.com", "outcome": "updated" }
],
"next_cursor": null
}
} Pagination
/v1/contacts is cursor-paginated (limit default 50, max 200; opaque cursor). /v1/topics is not paginated — SES caps a contact list at 50 topics, so every topic is returned in one response. The list response carries data plus a pagination object with has_more and next_cursor.
Filtering contacts
GET /v1/contacts accepts these filters, combined with AND:
| Parameter | Meaning |
|---|---|
topic | Only contacts with an explicit preference for this topic (the topic’s name). |
subscription_status | OPT_IN or OPT_OUT, scoping the topic filter. Requires topic — sending it alone returns 422 validation_failed. |
unsubscribe_all | true/false — the contact’s master opt-out flag. |
attribute_key + attribute_value | Match a custom attribute by string equality. Both must be sent together. |
Empty and un-synced states
- A contact list that hasn’t synced yet returns
404 contact_list_not_syncedfrom/v1/contact-list— a distinct code so you can detect the un-synced state and poll. See Errors. /v1/contactsand/v1/topicsreturn an empty result (an empty page /[]) rather than 404 when no list is synced.GET /v1/contacts/{email}returns404 not_foundfor both an unknown contact and a cross-org miss — it never leaks which.
Worked example: enumerate everyone subscribed to a topic
Combine topic with subscription_status=OPT_IN and follow the cursor to the end.
# First page of contacts opted in to the "newsletter" topic
curl -s -D - 'https://api.sendops.dev/v1/contacts?topic=newsletter&subscription_status=OPT_IN&limit=200' \
-H "Authorization: Bearer sk_live_..." async function* subscribers(topic) {
let url = new URL(
`/v1/contacts?topic=${encodeURIComponent(topic)}&subscription_status=OPT_IN&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 c of body.data) yield c.email;
url = body.pagination.next_cursor
? new URL(
`/v1/contacts?topic=${encodeURIComponent(topic)}&subscription_status=OPT_IN&limit=200&cursor=${body.pagination.next_cursor}`,
"https://api.sendops.dev",
).toString()
: null;
}
}
for await (const email of subscribers("newsletter")) {
console.log(email);
} Don't use this to gate sending
This enumerates the list for export or audit. To respect opt-outs at send time, send through SES SendEmail with ListManagementOptions — SES applies the per-topic subscription itself. The API is for reading state, not for a pre-send opt-out check.
Worked example: fetch one recipient’s preferences
Percent-encode @ as %40 in the path. The response includes the contact’s attributes and every explicit topic preference.
curl -s 'https://api.sendops.dev/v1/contacts/jane%40example.com' \
-H "Authorization: Bearer sk_live_..." {
"email": "jane@example.com",
"unsubscribe_all": false,
"attributes": { "plan": "pro", "region": "eu" },
"topic_preferences": [
{ "topic_name": "newsletter", "subscription_status": "OPT_IN" },
{ "topic_name": "promotions", "subscription_status": "OPT_OUT" }
],
"created_at": "2026-03-02T11:04:00Z",
"updated_at": "2026-05-18T08:21:00Z"
} Topics the contact has never set are absent from topic_preferences — the topic’s default_subscription_status (from GET /v1/topics) applies at send time, in SES.