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. |
brand | string | Brand slug; falls back to the project default. |
source | string | Free-form provenance label (signup-form, import). |
tags | string[] | Replaces the existing tags. |
consent | object | { source?, ip?, userAgent? } — compliance evidence for the opt-in. |
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",
"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 | Email substring match. |
limit | number | Rows per page. |
cursor | string | Opaque cursor from a previous nextCursor. |
curl "https://mailroom.example.com/api/v1/contacts?status=subscribed&limit=100" \
-H "Authorization: Bearer pk_live_…"
{ "ok": true, "contacts": [ … ], "nextCursor": "eyJpZCI6…" }
nextCursor is null on the last page. Keep the other 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.