Contacts
| Method | Path | Scope | SDK |
|---|---|---|---|
POST | /api/v1/contacts | contacts:write | contacts.upsert |
GET | /api/v1/contacts | contacts:read | contacts.list / listPage / listAll |
GET | /api/v1/contacts/:id | contacts:read | contacts.get |
POST | /api/v1/contacts/:id/unsubscribe | contacts:write | contacts.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.
| Field | Type | Notes |
|---|---|---|
email | string | Required. Must be a valid address. |
id | uuid | Honored on insert only — lets a migration preserve existing ids so already-issued unsubscribe links keep resolving. |
status | subscribed | unsubscribed | pending | bounced | Defaults to subscribed on insert. Omit it on an existing contact and their status is left untouched. |
resubscribe | boolean | Required to move a contact out of unsubscribed or bounced — see below. |
brand | string | Brand slug. An existing contact keeps the brand they have; new contacts get the project default. |
source | string | Free-form provenance label (signup-form, import). |
firstName | string | null | Given name. Omit to preserve an existing value; send null to clear it. |
lastName | string | null | Family name. Omit to preserve an existing value; send null to clear it. |
displayName | string | null | Preferred whole-name presentation. Omit to preserve; send null to clear it. |
tags | string[] | Replaces the existing tags — [] clears them, and it never merges. |
consent | object | { 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
| Query | Type | Notes |
|---|---|---|
status | string | Filter by status. |
tag | string | Contacts carrying this tag. |
q | string | Case-insensitive email, first-name, last-name, or display-name substring match. |
limit | number | Rows per page. |
cursor | string | Opaque cursor from nextCursor or previousCursor. |
direction | next | previous | Read 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.