# 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.


  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`](/api-reference/endpoints/topics/get-contact-list) |
| Contact Lists → **Topics** tab | [`GET /v1/topics`](/api-reference/endpoints/topics/list-topics) |
| Contact Lists → **Contacts** tab (search/filter) | [`GET /v1/contacts`](/api-reference/endpoints/contacts/list-contacts) |
| A single contact's detail page | [`GET /v1/contacts/{email}`](/api-reference/endpoints/contacts/get-contact) |
| Add a contact | [`POST /v1/contacts`](/api-reference/endpoints/contacts/create-contact) |
| Edit a contact (full replace) | [`PUT /v1/contacts/{email}`](/api-reference/endpoints/contacts/replace-contact) |
| Edit a contact (partial) | [`PATCH /v1/contacts/{email}`](/api-reference/endpoints/contacts/patch-contact) |
| Delete a contact | [`DELETE /v1/contacts/{email}`](/api-reference/endpoints/contacts/delete-contact) |
| Import contacts (CSV/bulk) | [`POST /v1/contacts/bulk`](/api-reference/endpoints/contacts/bulk-import-contacts) → [`GET /v1/contacts/bulk/{job_id}`](/api-reference/endpoints/contacts/get-bulk-job) |

## 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.



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`](/api-reference/endpoints/contacts/create-contact) | **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}`](/api-reference/endpoints/contacts/replace-contact) | **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}`](/api-reference/endpoints/contacts/patch-contact) | **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](/api-reference/lists-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}`](/api-reference/endpoints/contacts/upsert-contact-by-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.



## 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.



## 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}`](/api-reference/endpoints/contacts/get-bulk-job) 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.





## Pagination

`/v1/contacts` is [cursor-paginated](/api-reference/pagination) (`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_synced`** from `/v1/contact-list` — a distinct code so you can detect the un-synced state and poll. See [Errors](/api-reference/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.






  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.





Topics the contact has never set are absent from `topic_preferences` — the topic's `default_subscription_status` (from [`GET /v1/topics`](/api-reference/endpoints/topics/list-topics)) applies at send time, in SES.