Skip to main content

Contacts

MethodPathScopeSDK
POST/api/v1/contactscontacts:writecontacts.upsert
GET/api/v1/contactscontacts:readcontacts.list / listPage / listAll
GET/api/v1/contacts/:idcontacts:readcontacts.get
POST/api/v1/contacts/:id/unsubscribecontacts:writecontacts.unsubscribe

POST /api/v1/contacts​

Create or update contacts by email. Accepts a single object or an array of up to 1000. Idempotent on (project, email): an existing contact is updated and keeps its id.

FieldTypeNotes
emailstringRequired. Must be a valid address.
iduuidHonored on insert only — lets a migration preserve existing ids so already-issued unsubscribe links keep resolving.
statussubscribed | unsubscribed | pending | bouncedDefaults to subscribed on insert. Omit it on an existing contact and their status is left untouched.
resubscribebooleanRequired to move a contact out of unsubscribed or bounced — see below.
brandstringBrand slug. An existing contact keeps the brand they have; new contacts get the project default.
sourcestringFree-form provenance label (signup-form, import).
firstNamestring | nullGiven name. Omit to preserve an existing value; send null to clear it.
lastNamestring | nullFamily name. Omit to preserve an existing value; send null to clear it.
displayNamestring | nullPreferred whole-name presentation. Omit to preserve; send null to clear it.
tagsstring[]Replaces the existing tags — [] clears them, and it never merges.
consentobject{ source?, ip?, userAgent? } — compliance evidence for the opt-in. On an existing contact this is the only thing that can rewrite it.

Omitted fields are left alone​

Every field except email is optional, and leaving one out means "don't change it" — not "reset it". Only what you send is written:

{ "email": "ada@example.com", "firstName": "Ada", "lastName": "Lovelace", "tags": ["vip"] }

…sets the supplied names and tags and touches nothing else: her status, source, brand, displayName, and consent record all survive. Send null (or [] for tags) to clear a field deliberately.

This matters because a contact is upserted by paths that have nothing to say about her: a transactional send, a sequence enrolment, a storefront event. None of those should be able to empty her tags or erase how she opted in.

The consent record (consent_source, consent_ip, consent_user_agent) is stricter still. On an existing contact only an explicit consent object moves it. On insert it is seeded from source and the calling request's IP and user agent — which is real evidence for a signup form, and would be a fabrication if a later server-to-server call restamped it.

Opting out is sticky​

unsubscribed and bounced are terminal. An upsert cannot move a contact out of either, whether you omit status or send status: "subscribed" explicitly. This is deliberate: the endpoint is the generalised footer-subscribe, so a form re-submission, a "sync my customers" job, or a re-imported CSV must not silently re-add someone who opted out.

To re-subscribe someone who genuinely opted back in, say so:

{ "email": "ada@example.com", "status": "subscribed", "resubscribe": true }

Moving a contact into unsubscribed or bounced always works and needs no flag. pending is not sticky — it means "not asked yet", not "opted out".

# no-ops on an unsubscribed contact — they stay unsubscribed
curl -X POST https://mailroom.example.com/api/v1/contacts \
-H "Authorization: Bearer pk_live_…" -H "Content-Type: application/json" \
-d '{"email":"ada@example.com"}'
curl -X POST https://mailroom.example.com/api/v1/contacts \
-H "Authorization: Bearer pk_live_…" \
-H "Content-Type: application/json" \
-d '[{"email":"ada@example.com","tags":["beta"],"source":"signup-form"},
{"email":"grace@example.com"}]'
{
"ok": true,
"contacts": [
{
"id": "9f0c1b7e-…",
"email": "ada@example.com",
"first_name": "Ada",
"last_name": "Lovelace",
"display_name": null,
"status": "subscribed",
"brand_id": "b1…",
"source": "signup-form",
"tags": ["beta"],
"created_at": "2026-07-28T10:12:03.221Z",
"updated_at": "2026-07-28T10:12:03.221Z"
}
]
}

422 invalid_request — bad email, empty array, or more than 1000 entries.

GET /api/v1/contacts​

QueryTypeNotes
statusstringFilter by status.
tagstringContacts carrying this tag.
qstringCase-insensitive email, first-name, last-name, or display-name substring match.
limitnumberRows per page.
cursorstringOpaque cursor from nextCursor or previousCursor.
directionnext | previousRead older or newer rows from cursor. Defaults to next.
curl "https://mailroom.example.com/api/v1/contacts?status=subscribed&limit=100" \
-H "Authorization: Bearer pk_live_…"
{
"ok": true,
"contacts": [ … ],
"totalCount": 247,
"nextCursor": "eyJpZCI6…",
"previousCursor": null
}

totalCount is the exact count for the active filters, independent of the current page. nextCursor reads older rows and previousCursor reads newer rows; the respective cursor is null at that end. Keep all filters identical while paging. See Pagination.

GET /api/v1/contacts/:id​

{ "ok": true, "contact": { "id": "9f0c…", "email": "ada@example.com", … } }

404 not_found — no contact with that id in this project.

POST /api/v1/contacts/:id/unsubscribe​

Programmatic opt-out. Same effect as the recipient clicking the unsubscribe link: status flips to unsubscribed and a contact.unsubscribed webhook fires.

{ "ok": true, "id": "9f0c…", "status": "unsubscribed" }

404 not_found — unknown contact id.

There is no delete endpoint; opting someone out is the terminal state.