MillionSend contacts, properties, segments, and topics
Contacts are team-global: one list per team, one row per (team, lowercased email). There is no "audiences" concept — POST /contacts creates directly, and segments replace audience lists (/audiences/{id}/contacts[...] still works as a legacy alias for Resend v5-style SDKs; the audience id is a segment id, and creating through it also joins that segment). All routes need Authorization: Bearer ms_... with a full_access key (a sending-only key gets 403 restricted_api_key). Base URL: https://api.millionsend.com (cloud) or the instance's API origin (default http://localhost:3001).
Contacts
Create — POST /contacts (200 { "object": "contact", "id": "<uuid>" }; duplicate email → 409 with name validation_error, message Contact already exists):
curl -X POST "$MILLIONSEND_BASE_URL/contacts" \
-H "Authorization: Bearer $MILLIONSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"email": "ana@example.com",
"first_name": "Ana",
"last_name": "Silva",
"unsubscribed": false,
"properties": { "plan": "pro", "seats": 4 },
"segments": [{ "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" }],
"topics": [{ "id": "<topic-uuid>", "subscription": "opt_in" }]
}'
email must be a bare address (no display name).
properties is a flat map; scalar values are accepted (stored as strings), nested objects/arrays are a 422. Reads type them back per the team's property definitions.
segments / topics are optional initial associations, written in the same transaction as the contact; a missing segment/topic id → 404.
Read/update/delete — the {id} path segment accepts either the contact UUID or its email (case-insensitive):
GET /contacts/{id} → full contact; each entry of properties is a typed wrapper { "type": "string" | "number", "value": ... } (the type comes from the team's property definitions, see below).
PATCH /contacts/{id} — any of first_name/last_name (nullable to clear), unsubscribed, properties (merged; a null value removes the key).
DELETE /contacts/{id} → { "object": "contact", "contact": "<uuid>", "deleted": true }. The contact's emails stay in the send log; ?erase=true also scrubs the address from email history, event payloads and API logs (GDPR/LGPD).
POST /contacts/batch/remove with { "ids": [...] } or { "emails": [...] } (exactly one, 1–1000; emails case-insensitive) → { "data": [{ "object": "contact", "contact": "<uuid>", "deleted": true }, ...] } listing only the rows actually deleted. MillionSend extension (Resend deletes one at a time). Deleting keeps the contacts' emails in the send log; add "erase": true to also scrub each address from email history, event payloads and API logs (GDPR/LGPD), the same as DELETE /contacts/{id}?erase=true. MCP: delete_contacts (with erase).
GET /contacts/{id}/topics → every team topic with the contact's effective subscription (opt_in | opt_out, defaults applied), explicit (chosen vs. default) and visibility (public | private — the hosted page lists public topics only).
POST /contacts/{id}/preferences-link (id or email, no body) → { "object": "preferences_link", "contact": "<uuid>", "url": "https://.../unsubscribe/<token>" }: the contact's hosted preference center, the same page their emails' unsubscribe links open (public topics + a global unsubscribe button). No expiry — whoever holds it can change that contact's preferences, so show it only to the contact (e.g. a "manage email preferences" link in your settings screen). 422 when the instance lacks APP_BASE_URL. MCP: create_contact_preferences_link.
- A contact's global unsubscribe (
unsubscribed: true, or the retained origin: "unsubscribe" suppression) blocks topic sends and broadcasts only; POST /emails without topic_id (password resets, receipts) still delivers — Resend semantics ("unsubscribed from all Broadcasts"). Bounce/complaint/manual suppressions block everything.
- Contact changes publish webhooks (
contact.created · contact.updated · contact.deleted · contact.unsubscribed · contact.resubscribed · contact.topic_opt_in · contact.topic_opt_out, each with source) — see the millionsend-webhooks skill.
GET /contacts?limit=&after=|before= — keyset pagination: limit 1–100 (default 20), cursors are item ids, after/before mutually exclusive; response { object: "list", data: [...], has_more }.
GET /contacts?include=properties,topics (also on GET /segments/{id}/contacts) — MillionSend extension: attaches to every item the typed properties map and the topics rows the single GETs return, so an audience reads in one request per 100 contacts; without include the items keep the Resend shape.
POST /contacts/batch/get with { "contacts": [{ "id": "…" } | { "email": "…" }, …], "include": ["properties", "topics"] } (1–1000 entries, emails case-insensitive) → { "object": "list", "data": [<contact objects in request order>], "missing": [{ "index", "id" | "email" }] }. MillionSend extension; one call is one request against the rate limit — the right shape for a per-recipient unsubscribe guard before a send.
Unsubscribe semantics: "unsubscribed": true records the timestamp and excludes the contact from all broadcasts (transactional POST /emails sends are unaffected). Separate from this, hard bounces and complaints land on the team suppression list (below), which blocks both broadcasts and transactional sends. Broadcast emails carry RFC 8058 one-click unsubscribe links/headers automatically; recipients who click get unsubscribed: true set for them and a retained suppression entry with origin unsubscribe — only an explicit PATCH /contacts/{id} with "unsubscribed": false clears it (re-creating or batch-importing the address never does).
Bulk create — POST /contacts/batch
A MillionSend extension (Resend imports contacts only via CSV): a JSON array of 1–1000 POST /contacts bodies, written in one transaction. The dashboard's CSV import is the other bulk path.
curl -X POST "$MILLIONSEND_BASE_URL/contacts/batch?on_conflict=upsert" \
-H "Authorization: Bearer $MILLIONSEND_API_KEY" \
-H "Content-Type: application/json" \
-H "x-batch-validation: permissive" \
-d '[
{ "email": "ana@example.com", "first_name": "Ana", "properties": { "plan": "pro" } },
{ "email": "bob@example.com", "segments": [{ "id": "<segment-uuid>" }] }
]'
# → { "data": [{ "object": "contact", "index": 0, "id": "<uuid>", "status": "updated" }, { "object": "contact", "index": 1, "id": "<uuid>", "status": "created" }],
# "counts": { "created": 1, "updated": 1, "skipped": 0, "failed": 0 } }
on_conflict (query, default error) — what to do with an email that already belongs to a contact, and with an email repeated inside the batch: error → the item fails (409 Contact already exists / 422 Duplicate email in batch); skip → existing contact (or first occurrence) untouched, reported as status: "skipped" with its id; upsert → merge: first_name/last_name only when provided, properties merged key by key, segments added, topics upserted; repeats collapse into one write (later scalars win, associations union).
- Never re-subscribes:
unsubscribed: true opts out; unsubscribed: false on an already-unsubscribed contact is ignored. Suppressions are never touched. Use PATCH /contacts/{id} to re-subscribe deliberately.
x-batch-validation (header, default strict) — strict: the first failing item (by index) fails the whole batch with its own status and a contacts.<index>: <message> prefix, nothing written. permissive: valid subset written, failures listed as errors: [{ index, message }].
- Response:
data in request order, one entry per successful item; counts sum to the request length; errors only in permissive mode. Unknown segment/topic id → 404 not_found; 0 or >1000 items, unknown on_conflict/header value → 422.
Contact properties — typed keys at /contact-properties
Property definitions give each key a type (string | number) and an optional fallback used when a contact lacks the key (e.g. in broadcast merge fields):
curl -X POST "$MILLIONSEND_BASE_URL/contact-properties" \
-H "Authorization: Bearer $MILLIONSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "key": "plan", "type": "string", "fallback_value": "free" }'
# → { "object": "contact_property", "id": "<uuid>" }
key ≤ 200 chars; fallback_value must match type (a number property rejects non-numeric fallbacks); string fallbacks ≤ 1000 chars.
GET /contact-properties (paginated list) · GET /contact-properties/{id} · PATCH /contact-properties/{id} (only fallback_value is updatable — key and type are fixed) · DELETE /contact-properties/{id} → { ..., "deleted": true }.
Segments — saved filters or manual lists at /segments
A segment resolves to: contacts matching its saved filter (if any) OR contacts added as manual members. Omit filter on create for a purely manual segment.
curl -X POST "$MILLIONSEND_BASE_URL/segments" \
-H "Authorization: Bearer $MILLIONSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Pro users",
"filter": {
"match": "all",
"conditions": [
{ "field": "property:plan", "op": "equals", "value": "pro" },
{ "field": "unsubscribed", "op": "is_false", "value": null }
]
}
}'
Filter grammar (validated server-side, unknown field/op → 422):
match: "all" (AND) or "any" (OR).
- Text fields
email, first_name, last_name, and property:<key> take ops equals, not_equals, contains, starts_with, ends_with, is_set, is_not_set. Presence ops (is_set/is_not_set) take "value": null; the rest need a string value.
unsubscribed takes is_true / is_false (value null).
created_at takes before / after with an ISO date string value.
Routes: POST /segments, GET /segments (paginated like contacts), GET /segments/{id} (includes live contact_count), PATCH /segments/{id} (name and/or filter; "filter": null clears it, turning the segment manual-only), DELETE /segments/{id} (409 conflict if a broadcast references it).
Membership (manual members; the contact path accepts UUID or email):
POST /contacts/{id}/segments/{segmentId} — add; idempotent upsert (adding twice is fine) → { "id": "<contact uuid>" }.
DELETE /contacts/{id}/segments/{segmentId} — remove → { "id": ..., "audienceId": "<segment uuid>", "deleted": true }; removing a non-member is a 404.
GET /segments/{id}/contacts?limit=&after= — list the segment's resolved contacts (filter matches ∪ manual members). SDK: ms.contacts.list({ segmentId }).
Topics — subscription preferences
Topics model opt-in/opt-out categories (e.g. "Product news"). default_subscription is fixed at creation: opt_in = subscribed unless the contact opts out; opt_out = unsubscribed unless they opt in.
POST /topics { "name": "Product news", "description": "...", "default_subscription": "opt_in", "visibility": "public" } → { id }. visibility (private | public, MillionSend extension): public topics always show on the hosted unsubscribe/preferences page; private topics show there only when reached through their own topic link.
GET /topics → { "data": [...] } (no pagination); GET /topics/{id}; PATCH /topics/{id} (name, description, visibility — default_subscription is immutable and silently ignored if sent); DELETE /topics/{id} (409 conflict if a broadcast references it).
- Set a contact's subscriptions —
PATCH /contacts/{id}/topics with a bare array body:
curl -X PATCH "$MILLIONSEND_BASE_URL/contacts/ana@example.com/topics" \
-H "Authorization: Bearer $MILLIONSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '[{ "id": "<topic-uuid>", "subscription": "opt_out" }]'
- Read them back —
GET /contacts/{id}/topics → { object: "list", data: [{ id, name, description, subscription, explicit }], has_more: false }: every topic of the team with the contact's effective subscription (explicit choice, else the topic default) and explicit: false when it is the default. id is the contact id or email.
Suppressions — /suppressions
The team's do-not-send list. bounce, complaint and manual entries block every send (POST /emails strips suppressed recipients; all-to-suppressed → 422 All recipients are suppressed); unsubscribe entries block topic sends (topic_id) and broadcasts only, so topic-less transactional sends still deliver. Each entry: { id, email, origin, source_id, created_at } — origin is bounce | complaint | manual | unsubscribe (the last is a MillionSend superset value: retained one-click opt-outs), source_id the email id whose bounce/complaint created it (else null). Same wire as Resend's suppressions surface, so the resend SDK's suppressions.add/get/list/remove and suppressions.batch.add/remove work as-is.
# block one address (origin manual). Idempotent: already suppressed for any origin → same row, its existing id returned
curl -X POST "$MILLIONSEND_BASE_URL/suppressions" \
-H "Authorization: Bearer $MILLIONSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "email": "bounced@example.com" }'
# → { "object": "suppression", "id": "<uuid>" }
# bulk (up to 1000 per call; Resend caps at 100) — e.g. carrying a bounce list over from another provider
curl -X POST "$MILLIONSEND_BASE_URL/suppressions/batch/add" \
-H "Authorization: Bearer $MILLIONSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "emails": ["a@example.com", "b@example.com"] }'
# → { "data": [{ "object": "suppression", "id": "..." }, ...] } — one per distinct address, input order
GET /suppressions?limit=&after=|before=&origin=bounce — keyset list; unknown origin → 422.
GET /suppressions/{id} / DELETE /suppressions/{id} — {id} is the suppression UUID or the email address; 404 not_found otherwise. Delete → { "object": "suppression", "id": ..., "deleted": true } and the address can receive mail again (it is re-suppressed automatically on the next bounce/complaint).
POST /suppressions/batch/remove — body { "emails": [...] } or { "ids": [...] } (exactly one, 1–1000); returns only the rows actually removed.
- Addresses erased under GDPR/LGPD keep blocking sends but are hidden from the list and from lookups by email; they are reachable by id only (email reads
"[erased]"), and re-adding the address returns that id without restoring it.
SDK equivalents
Node: ms.contacts.create({...}), ms.contacts.list({ segmentId? }), ms.contacts.get({ id | email }), ms.contacts.update(...), ms.contacts.remove(...), ms.contacts.segments.add/remove(...), plus ms.segments.* and ms.topics.* — same shapes as the REST bodies. Python: millionsend.Contacts.create({...}), millionsend.Contacts.get(email="..."), millionsend.Segments.*, millionsend.Topics.*. From SDK 0.4.0 the rest of this surface is wrapped too, with the Resend SDK's names: Node ms.contactProperties.*, ms.contacts.batch.create(items, { onConflict, batchValidation }), ms.suppressions.add/get/list/remove and ms.suppressions.batch.add/remove; Python millionsend.ContactProperties.*, millionsend.Contacts.Batch.create(items, batch_validation=...), millionsend.Suppressions.* and millionsend.Suppressions.Batch.*; the other seven SDKs mirror the same shape. Errors follow { statusCode, name, message } with names like validation_error, not_found, conflict, restricted_api_key.
1---2name: millionsend-contacts3description: Manage contacts, typed contact properties, segments, topics, and the suppression list on MillionSend (Resend-compatible email API). Use when creating, listing, updating, or deleting contacts, bulk-loading contacts via POST /contacts/batch (skip/upsert), defining custom property keys, handling unsubscribes, building segments (saved filters or manual lists), managing segment membership, managing topic subscriptions, or adding/removing suppressed addresses via the MillionSend REST API or SDKs.4---56# MillionSend contacts, properties, segments, and topics78Contacts are **team-global**: one list per team, one row per `(team, lowercased email)`. There is no "audiences" concept — `POST /contacts` creates directly, and segments replace audience lists (`/audiences/{id}/contacts[...]` still works as a legacy alias for Resend v5-style SDKs; the audience id is a segment id, and creating through it also joins that segment). All routes need `Authorization: Bearer ms_...` with a **full_access** key (a sending-only key gets 403 `restricted_api_key`). Base URL: `https://api.millionsend.com` (cloud) or the instance's API origin (default `http://localhost:3001`).910## Contacts1112Create — `POST /contacts` (200 `{ "object": "contact", "id": "<uuid>" }`; duplicate email → 409 with name `validation_error`, message `Contact already exists`):1314```sh15curl -X POST "$MILLIONSEND_BASE_URL/contacts" \16 -H "Authorization: Bearer $MILLIONSEND_API_KEY" \17 -H "Content-Type: application/json" \18 -d '{19 "email": "ana@example.com",20 "first_name": "Ana",21 "last_name": "Silva",22 "unsubscribed": false,23 "properties": { "plan": "pro", "seats": 4 },24 "segments": [{ "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" }],25 "topics": [{ "id": "<topic-uuid>", "subscription": "opt_in" }]26 }'27```2829- `email` must be a bare address (no display name).30- `properties` is a flat map; scalar values are accepted (stored as strings), nested objects/arrays are a 422. Reads type them back per the team's property definitions.31- `segments` / `topics` are optional initial associations, written in the same transaction as the contact; a missing segment/topic id → 404.3233Read/update/delete — the `{id}` path segment accepts **either the contact UUID or its email** (case-insensitive):3435- `GET /contacts/{id}` → full contact; each entry of `properties` is a typed wrapper `{ "type": "string" | "number", "value": ... }` (the type comes from the team's property definitions, see below).36- `PATCH /contacts/{id}` — any of `first_name`/`last_name` (nullable to clear), `unsubscribed`, `properties` (merged; a `null` value removes the key).37- `DELETE /contacts/{id}` → `{ "object": "contact", "contact": "<uuid>", "deleted": true }`. The contact's emails stay in the send log; `?erase=true` also scrubs the address from email history, event payloads and API logs (GDPR/LGPD).38- `POST /contacts/batch/remove` with `{ "ids": [...] }` **or** `{ "emails": [...] }` (exactly one, 1–1000; emails case-insensitive) → `{ "data": [{ "object": "contact", "contact": "<uuid>", "deleted": true }, ...] }` listing only the rows actually deleted. MillionSend extension (Resend deletes one at a time). Deleting keeps the contacts' emails in the send log; add `"erase": true` to also scrub each address from email history, event payloads and API logs (GDPR/LGPD), the same as `DELETE /contacts/{id}?erase=true`. MCP: `delete_contacts` (with `erase`).39- `GET /contacts/{id}/topics` → every team topic with the contact's effective `subscription` (`opt_in` | `opt_out`, defaults applied), `explicit` (chosen vs. default) and `visibility` (`public` | `private` — the hosted page lists public topics only).40- `POST /contacts/{id}/preferences-link` (id or email, no body) → `{ "object": "preferences_link", "contact": "<uuid>", "url": "https://.../unsubscribe/<token>" }`: the contact's hosted preference center, the same page their emails' unsubscribe links open (public topics + a global unsubscribe button). No expiry — whoever holds it can change that contact's preferences, so show it only to the contact (e.g. a "manage email preferences" link in your settings screen). 422 when the instance lacks `APP_BASE_URL`. MCP: `create_contact_preferences_link`.41- A contact's global unsubscribe (`unsubscribed: true`, or the retained `origin: "unsubscribe"` suppression) blocks **topic sends and broadcasts only**; `POST /emails` without `topic_id` (password resets, receipts) still delivers — Resend semantics ("unsubscribed from all Broadcasts"). Bounce/complaint/manual suppressions block everything.42- Contact changes publish webhooks (`contact.created` · `contact.updated` · `contact.deleted` · `contact.unsubscribed` · `contact.resubscribed` · `contact.topic_opt_in` · `contact.topic_opt_out`, each with `source`) — see the millionsend-webhooks skill.43- `GET /contacts?limit=&after=|before=` — keyset pagination: `limit` 1–100 (default 20), cursors are item ids, `after`/`before` mutually exclusive; response `{ object: "list", data: [...], has_more }`.44- `GET /contacts?include=properties,topics` (also on `GET /segments/{id}/contacts`) — MillionSend extension: attaches to every item the typed `properties` map and the `topics` rows the single GETs return, so an audience reads in one request per 100 contacts; without `include` the items keep the Resend shape.45- `POST /contacts/batch/get` with `{ "contacts": [{ "id": "…" } | { "email": "…" }, …], "include": ["properties", "topics"] }` (1–1000 entries, emails case-insensitive) → `{ "object": "list", "data": [<contact objects in request order>], "missing": [{ "index", "id" | "email" }] }`. MillionSend extension; one call is one request against the rate limit — the right shape for a per-recipient unsubscribe guard before a send.4647Unsubscribe semantics: `"unsubscribed": true` records the timestamp and excludes the contact from **all broadcasts** (transactional `POST /emails` sends are unaffected). Separate from this, hard bounces and complaints land on the team suppression list (below), which blocks both broadcasts and transactional sends. Broadcast emails carry RFC 8058 one-click unsubscribe links/headers automatically; recipients who click get `unsubscribed: true` set for them **and** a retained suppression entry with origin `unsubscribe` — only an explicit `PATCH /contacts/{id}` with `"unsubscribed": false` clears it (re-creating or batch-importing the address never does).4849### Bulk create — POST /contacts/batch5051A MillionSend extension (Resend imports contacts only via CSV): a JSON **array** of 1–1000 `POST /contacts` bodies, written in one transaction. The dashboard's CSV import is the other bulk path.5253```sh54curl -X POST "$MILLIONSEND_BASE_URL/contacts/batch?on_conflict=upsert" \55 -H "Authorization: Bearer $MILLIONSEND_API_KEY" \56 -H "Content-Type: application/json" \57 -H "x-batch-validation: permissive" \58 -d '[59 { "email": "ana@example.com", "first_name": "Ana", "properties": { "plan": "pro" } },60 { "email": "bob@example.com", "segments": [{ "id": "<segment-uuid>" }] }61 ]'62# → { "data": [{ "object": "contact", "index": 0, "id": "<uuid>", "status": "updated" }, { "object": "contact", "index": 1, "id": "<uuid>", "status": "created" }],63# "counts": { "created": 1, "updated": 1, "skipped": 0, "failed": 0 } }64```6566- `on_conflict` (query, default `error`) — what to do with an email that already belongs to a contact, and with an email repeated inside the batch: `error` → the item fails (409 `Contact already exists` / 422 `Duplicate email in batch`); `skip` → existing contact (or first occurrence) untouched, reported as `status: "skipped"` with its id; `upsert` → merge: `first_name`/`last_name` only when provided, `properties` merged key by key, `segments` added, `topics` upserted; repeats collapse into one write (later scalars win, associations union).67- **Never re-subscribes**: `unsubscribed: true` opts out; `unsubscribed: false` on an already-unsubscribed contact is ignored. Suppressions are never touched. Use `PATCH /contacts/{id}` to re-subscribe deliberately.68- `x-batch-validation` (header, default `strict`) — strict: the first failing item (by index) fails the whole batch with its own status and a `contacts.<index>: <message>` prefix, nothing written. `permissive`: valid subset written, failures listed as `errors: [{ index, message }]`.69- Response: `data` in request order, one entry per successful item; `counts` sum to the request length; `errors` only in permissive mode. Unknown segment/topic id → 404 `not_found`; 0 or >1000 items, unknown `on_conflict`/header value → 422.7071## Contact properties — typed keys at /contact-properties7273Property **definitions** give each key a type (`string` | `number`) and an optional fallback used when a contact lacks the key (e.g. in broadcast merge fields):7475```sh76curl -X POST "$MILLIONSEND_BASE_URL/contact-properties" \77 -H "Authorization: Bearer $MILLIONSEND_API_KEY" \78 -H "Content-Type: application/json" \79 -d '{ "key": "plan", "type": "string", "fallback_value": "free" }'80# → { "object": "contact_property", "id": "<uuid>" }81```8283- `key` ≤ 200 chars; `fallback_value` must match `type` (a `number` property rejects non-numeric fallbacks); string fallbacks ≤ 1000 chars.84- `GET /contact-properties` (paginated list) · `GET /contact-properties/{id}` · `PATCH /contact-properties/{id}` (**only** `fallback_value` is updatable — key and type are fixed) · `DELETE /contact-properties/{id}` → `{ ..., "deleted": true }`.8586## Segments — saved filters or manual lists at /segments8788A segment resolves to: contacts matching its saved **filter** (if any) **OR** contacts added as manual **members**. Omit `filter` on create for a purely manual segment.8990```sh91curl -X POST "$MILLIONSEND_BASE_URL/segments" \92 -H "Authorization: Bearer $MILLIONSEND_API_KEY" \93 -H "Content-Type: application/json" \94 -d '{95 "name": "Pro users",96 "filter": {97 "match": "all",98 "conditions": [99 { "field": "property:plan", "op": "equals", "value": "pro" },100 { "field": "unsubscribed", "op": "is_false", "value": null }101 ]102 }103 }'104```105106Filter grammar (validated server-side, unknown field/op → 422):107108- `match`: `"all"` (AND) or `"any"` (OR).109- Text fields `email`, `first_name`, `last_name`, and `property:<key>` take ops `equals`, `not_equals`, `contains`, `starts_with`, `ends_with`, `is_set`, `is_not_set`. Presence ops (`is_set`/`is_not_set`) take `"value": null`; the rest need a string value.110- `unsubscribed` takes `is_true` / `is_false` (value null).111- `created_at` takes `before` / `after` with an ISO date string value.112113Routes: `POST /segments`, `GET /segments` (paginated like contacts), `GET /segments/{id}` (includes live `contact_count`), `PATCH /segments/{id}` (name and/or filter; `"filter": null` clears it, turning the segment manual-only), `DELETE /segments/{id}` (409 `conflict` if a broadcast references it).114115Membership (manual members; the contact path accepts UUID or email):116117- `POST /contacts/{id}/segments/{segmentId}` — add; idempotent upsert (adding twice is fine) → `{ "id": "<contact uuid>" }`.118- `DELETE /contacts/{id}/segments/{segmentId}` — remove → `{ "id": ..., "audienceId": "<segment uuid>", "deleted": true }`; removing a non-member is a 404.119- `GET /segments/{id}/contacts?limit=&after=` — list the segment's resolved contacts (filter matches ∪ manual members). SDK: `ms.contacts.list({ segmentId })`.120121## Topics — subscription preferences122123Topics model opt-in/opt-out categories (e.g. "Product news"). `default_subscription` is fixed at creation: `opt_in` = subscribed unless the contact opts out; `opt_out` = unsubscribed unless they opt in.124125- `POST /topics` `{ "name": "Product news", "description": "...", "default_subscription": "opt_in", "visibility": "public" }` → `{ id }`. `visibility` (`private` | `public`, MillionSend extension): public topics always show on the hosted unsubscribe/preferences page; private topics show there only when reached through their own topic link.126- `GET /topics` → `{ "data": [...] }` (no pagination); `GET /topics/{id}`; `PATCH /topics/{id}` (name, description, visibility — `default_subscription` is immutable and silently ignored if sent); `DELETE /topics/{id}` (409 `conflict` if a broadcast references it).127- Set a contact's subscriptions — `PATCH /contacts/{id}/topics` with a **bare array** body:128129```sh130curl -X PATCH "$MILLIONSEND_BASE_URL/contacts/ana@example.com/topics" \131 -H "Authorization: Bearer $MILLIONSEND_API_KEY" \132 -H "Content-Type: application/json" \133 -d '[{ "id": "<topic-uuid>", "subscription": "opt_out" }]'134```135- Read them back — `GET /contacts/{id}/topics` → `{ object: "list", data: [{ id, name, description, subscription, explicit }], has_more: false }`: every topic of the team with the contact's effective `subscription` (explicit choice, else the topic default) and `explicit: false` when it is the default. `id` is the contact id or email.136137## Suppressions — /suppressions138139The team's do-not-send list. `bounce`, `complaint` and `manual` entries block every send (`POST /emails` strips suppressed recipients; all-`to`-suppressed → 422 `All recipients are suppressed`); `unsubscribe` entries block topic sends (`topic_id`) and broadcasts only, so topic-less transactional sends still deliver. Each entry: `{ id, email, origin, source_id, created_at }` — `origin` is `bounce` | `complaint` | `manual` | `unsubscribe` (the last is a MillionSend superset value: retained one-click opt-outs), `source_id` the email id whose bounce/complaint created it (else null). Same wire as Resend's `suppressions` surface, so the `resend` SDK's `suppressions.add/get/list/remove` and `suppressions.batch.add/remove` work as-is.140141```sh142# block one address (origin manual). Idempotent: already suppressed for any origin → same row, its existing id returned143curl -X POST "$MILLIONSEND_BASE_URL/suppressions" \144 -H "Authorization: Bearer $MILLIONSEND_API_KEY" \145 -H "Content-Type: application/json" \146 -d '{ "email": "bounced@example.com" }'147# → { "object": "suppression", "id": "<uuid>" }148149# bulk (up to 1000 per call; Resend caps at 100) — e.g. carrying a bounce list over from another provider150curl -X POST "$MILLIONSEND_BASE_URL/suppressions/batch/add" \151 -H "Authorization: Bearer $MILLIONSEND_API_KEY" \152 -H "Content-Type: application/json" \153 -d '{ "emails": ["a@example.com", "b@example.com"] }'154# → { "data": [{ "object": "suppression", "id": "..." }, ...] } — one per distinct address, input order155```156157- `GET /suppressions?limit=&after=|before=&origin=bounce` — keyset list; unknown `origin` → 422.158- `GET /suppressions/{id}` / `DELETE /suppressions/{id}` — `{id}` is the suppression UUID **or the email address**; 404 `not_found` otherwise. Delete → `{ "object": "suppression", "id": ..., "deleted": true }` and the address can receive mail again (it is re-suppressed automatically on the next bounce/complaint).159- `POST /suppressions/batch/remove` — body `{ "emails": [...] }` **or** `{ "ids": [...] }` (exactly one, 1–1000); returns only the rows actually removed.160- Addresses erased under GDPR/LGPD keep blocking sends but are hidden from the list and from lookups by email; they are reachable by id only (email reads `"[erased]"`), and re-adding the address returns that id without restoring it.161162## SDK equivalents163164Node: `ms.contacts.create({...})`, `ms.contacts.list({ segmentId? })`, `ms.contacts.get({ id | email })`, `ms.contacts.update(...)`, `ms.contacts.remove(...)`, `ms.contacts.segments.add/remove(...)`, plus `ms.segments.*` and `ms.topics.*` — same shapes as the REST bodies. Python: `millionsend.Contacts.create({...})`, `millionsend.Contacts.get(email="...")`, `millionsend.Segments.*`, `millionsend.Topics.*`. From SDK 0.4.0 the rest of this surface is wrapped too, with the Resend SDK's names: Node `ms.contactProperties.*`, `ms.contacts.batch.create(items, { onConflict, batchValidation })`, `ms.suppressions.add/get/list/remove` and `ms.suppressions.batch.add/remove`; Python `millionsend.ContactProperties.*`, `millionsend.Contacts.Batch.create(items, on_conflict=..., batch_validation=...)`, `millionsend.Suppressions.*` and `millionsend.Suppressions.Batch.*`; the other seven SDKs mirror the same shape. Errors follow `{ statusCode, name, message }` with names like `validation_error`, `not_found`, `conflict`, `restricted_api_key`.