Contacts & Topics

Search Documentation

Search across all developer documentation

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

DashboardAPI
Contact Lists → list header (name, counts, last sync)GET /v1/contact-list
Contact Lists → Topics tabGET /v1/topics
Contact Lists → Contacts tab (search/filter)GET /v1/contacts
A single contact’s detail pageGET /v1/contacts/{email}
Add a contactPOST /v1/contacts
Edit a contact (full replace)PUT /v1/contacts/{email}
Edit a contact (partial)PATCH /v1/contacts/{email}
Delete a contactDELETE /v1/contacts/{email}, or DELETE /v1/contacts/by-external-id/{external_id}
Import contacts (CSV/bulk)POST /v1/contacts/bulkGET /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:

ScopeGrantsPII / risk
api.topics.view/v1/topics, /v1/contact-listList-level metadata and the topic taxonomy — no recipient PII.
api.topics.managePOST /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.viewGET /v1/contacts, GET /v1/contacts/{email}Recipient emails, attributes, and preferences — PII. Grant deliberately.
api.contacts.managePOST/PUT/PATCH/DELETE /v1/contacts, POST /v1/contacts/bulk, PUT/DELETE /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:

VerbSemanticsMissing fields
POST /v1/contactsCreate 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. If you sync on your own identifier, delete on that instead — see deleting by external id.

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:

FieldMeaning
emailThe contact’s address. Required when creating; optional (but honoured) when updating.
attributesDesired attribute map, validated against the registry.
unsubscribe_allMaster opt-out. Omit (or send null) to leave the contact’s current opt-out state untouched.
topic_preferencesPer-topic subscription preferences.
listsStatic-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"]
}'

When the upsert conflicts

A 409 from this endpoint is not one failure — it’s three, and the right reaction differs for each. A client that retries all three is wrong twice; one that gives up on all three is wrong once. Each returns its own code:

codeWhat happenedWhat to do
contact_external_id_mismatchThe email you sent already belongs to a contact carrying a different external id.Don’t retry unchanged — the incumbent won’t move. Repeat with rebind_external_id: true to re-key it, or reconcile the two records yourself.
contact_email_takenThis external id’s contact exists, and the address you’re moving it to belongs to another contact.Reconcile the two records. rebind_external_id does not help here — it waives the external-id ownership guard, not this one.
contact_claim_raceA concurrent write claimed this external id mid-resolution.Retry. This is the only transient conflict here, and the only one that carries Retry-After.

contact_external_id_mismatch also carries incumbent_external_id (the id currently holding the address) and incumbent_archived. That second field is what turns re-keying from a guess into a decision:

An archived contact still owns its address

Deleting a contact archives it rather than removing it, and an archived contact keeps its email address and its external id. So when one of your users releases an address and another registers it, the archived contact blocks the new one — permanently, because nothing ever syncs a departed identity to move it off.

That case arrives as contact_external_id_mismatch with incumbent_archived: true, and it’s the one where rebind_external_id: true is unambiguously safe: you’re re-keying a record whose identity is already gone. A live incumbent (incumbent_archived: false) is a real collision — two of your current users appear to claim one address — and deserves a human, not an automatic rebind.

Deleting by external id

DELETE /v1/contacts/by-external-id/{external_id} retires the contact on the same key you created it with, whatever address it currently holds.

Prefer it over DELETE /v1/contacts/{email} whenever you sync on external id. A contact’s address can change — the PUT above changes it — and once it has, a delete keyed on the address you remember either misses the contact you meant to retire, or archives a different contact that has since taken that address over. You can’t detect either, because the by-email delete is idempotent and answers 204 in both cases.

curl -X DELETE 'https://api.sendops.dev/v1/contacts/by-external-id/cust_9182' \
-H "Authorization: Bearer sk_live_..."

Idempotent in every direction: 204 whether the contact was archived by this call, was already archived, or was never there. Same scope (api.contacts.manage), same archive-not-erase semantics as the by-email delete, and it emits the same exit transitions for every static List the contact belonged to.

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 POST and PATCH, lists is a directive object: add and remove apply a delta, or set replaces membership declaratively (an empty set clears every membership). add/remove and set are mutually exclusive.
  • On PUT, lists is a plain array = the exact membership. A present empty array clears all memberships; omitting lists (or sending null) 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, or an external_id conflict) 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.

Row fields

Beyond email, attributes, topic_preferences and lists, two fields exist specifically to make bulk the right tool for re-syncing an audience you already have:

FieldMeaning
unsubscribe_allMaster opt-out, tri-state. Omit it (or send null) and the contact’s current opt-out is left exactly as it is. Send true to opt them out, false to re-subscribe them.
external_idYour own identifier for this contact. The row then resolves exactly as PUT /v1/contacts/by-external-id/{external_id} does — same code path, so create/adopt/rebind behaviour is identical. Omit it for the plain email-keyed upsert, which leaves external_id untouched.
rebind_external_idWhen the row’s email already belongs to a contact carrying a different external id, the row fails rather than steal the contact from its current identity. Set this to true to rebind the email onto this row’s external_id instead — an authoritative “I own this identity, re-key it” write. Ignored when external_id is absent.

Omitting `unsubscribe_all` means “no change” — it did not always

Before 31 July 2026, a bulk row that omitted unsubscribe_all was treated as false and cleared the contact’s master opt-out. Re-importing an existing audience silently re-subscribed everyone in the batch, reported as outcome: "updated". That is fixed: an absent or null unsubscribe_all never changes opt-out state. Only an explicit true/false is a consent write.

If your integration was sending "unsubscribe_all": false on every row to work around this, stop — it now means “re-subscribe this person” and will be honoured literally.

Establishing your join key

Supplying external_id per row is what makes a bulk import usable as the first step of an externally-keyed sync. Without it, bulk-created contacts land with external_id unset, and you’d have to follow the import with one single-shot write per contact to attach your ids.

Resolution per row, given an external_id:

SituationOutcome
Nothing carries this external id, email unknownContact created with that external id
Email exists, its external_id is unsetContact adopted — the id is claimed
A contact already carries this external idThat contact is updated (its email can change)
Email exists but carries a different external idThat row fails (outcome: "failed"), with a reason naming the conflicting id. The rest of the batch still applies. Set rebind_external_id: true to re-key instead.
# Enqueue a bulk import, keyed by your own ids
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", "external_id": "cust_1", "attributes": { "plan": "pro" } },
    { "email": "b@example.com", "external_id": "cust_2", "unsubscribe_all": true },
    { "email": "c@example.com", "attributes": { "plan": "free" } }
  ]
}'
# → 202 { "job_id": "0192...", "status": "pending", "total": 3 }
# Row 3 omits external_id and unsubscribe_all: an email-keyed upsert that
# refreshes attributes and touches neither the external id nor consent.

# 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": 3,
"processed": 3,
"succeeded": 2,
"failed": 1,
"ses_pending": 2,
"org_ses_pending": 2,
"results": {
  "data": [
    { "index": 0, "email": "a@example.com", "outcome": "created" },
    { "index": 1, "email": "b@example.com", "outcome": "updated" },
    {
      "index": 2,
      "email": "c@example.com",
      "outcome": "failed",
      "reason": "that email already belongs to a contact with external_id \"cust_99\""
    }
  ],
  "next_cursor": null
}
}

status: "completed" means every row has been applied in SendOps. ses_pending counts how many of this job’s contacts have not yet been mirrored into your SES account — see How syncing to SES works below.

How syncing to SES works

SendOps holds your contacts and mirrors them into the SES contact list your AWS account owns. Since 31 July 2026 that mirroring is asynchronous, and it changes one thing worth building around:

A 2xx on a contact write means SendOps has accepted and durably stored the change. It does not mean SES has it yet.

This is almost always invisible, because everything you can observe through SendOps — segment membership, consent filtering, exports, contact reads, and who a broadcast actually sends to — reads SendOps’ own copy. A pending mirror holds nothing back. It matters in exactly one case: you’re also looking at the contact list in the AWS console, or another system reads SES directly.

Why it’s paced

AWS enforces an account-wide budget of roughly one contact request per second on the SES contact APIs. That ceiling is not ours and can’t be raised. Previously we spent it inside your request, which meant contact writes could return throttling errors sourced from your own AWS account. Now writes commit locally and a background worker drains them at whatever rate AWS allows.

The queue coalesces per contact: ten writes to the same address while the mirror is behind cost one SES call carrying the final state, not ten. So re-importing an audience whose attributes barely changed is far cheaper than the row count suggests — but a first-time import of 100,000 new contacts is still bounded by that one-per-second budget, and will take time to appear in the AWS console. It is fully usable in SendOps immediately.

Observing it

Two fields expose the mirror, and both are omitted or zero in the steady state — you only see them when something is actually outstanding:

WhereFieldMeaning
GET /v1/contacts/{email}, GET /v1/contactsses_syncPresent only while the contact hasn’t reached SES. Absent means synced. Carries state (pending or error), pending_since, and — for error only — a reason.
GET /v1/contacts/bulk/{job_id}ses_pendingHow many of this job’s contacts are still queued. null means “not computable for this job” (never zero) — returned when the job’s address set isn’t recoverable, e.g. a bulk update that selected contacts by filter. Fall back to org_ses_pending.
GET /v1/contacts/bulk/{job_id}org_ses_pendingYour organization’s entire pending mirror queue, which this job’s contacts are part of. Always present.
{
"email": "jane@example.com",
"unsubscribe_all": false,
"ses_sync": {
  "state": "pending",
  "pending_since": "2026-07-31T09:14:02Z"
}
}

When state is error

pending is the ordinary case — queued, or retrying a transient failure, and expected to clear without anyone doing anything. error means the mirror is blocked on something only you can fix in your AWS account, most often because the IAM role SendOps assumes was denied the SES contact permissions, or the account hit a contact-list limit. Retrying the write won’t help; the reason field says what to do, and the usual remedy is updating the SendOps CloudFormation stack in your AWS account to restore the missing permissions. The queue resumes on its own once access is restored — nothing is lost, because the worker pushes each contact’s current state rather than replaying a log.

reason is always our own sanitized wording. It never carries raw AWS error text, since that describes your account rather than your request.

You don't need to poll `ses_sync`

Treat it as a diagnostic, not a gate. Because SendOps’ copy is authoritative for everything the product does, waiting for ses_sync to disappear before continuing your integration adds latency and buys nothing. Reach for it when reconciling against the AWS console, or when investigating why a contact looks stale there.

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:

ParameterMeaning
topicOnly contacts with an explicit preference for this topic (the topic’s name).
subscription_statusOPT_IN or OPT_OUT, scoping the topic filter. Requires topic — sending it alone returns 422 validation_failed.
unsubscribe_alltrue/false — the contact’s master opt-out flag.
attribute_key + attribute_valueMatch 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_synced from /v1/contact-list — a distinct code so you can detect the un-synced state and poll. See Errors.
  • /v1/contacts and /v1/topics return an empty result (an empty page / []) rather than 404 when no list is synced.
  • GET /v1/contacts/{email} returns 404 not_found for 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 — for those, the topic’s default_subscription_status (from GET /v1/topics) applies, and SendOps enforces it at send time.

An absent topic therefore does not mean the contact will be mailed on it. On a topic whose default_subscription_status is OPT_OUT, a contact with no entry here has not consented and will not be sent that topic’s mail.