Contacts
Team-global contacts with subscribe state and custom properties.
Contacts are team-global: one list per team, one row per email address
(case-insensitive unique). There is no "audiences" concept — a contact belongs
to your team directly, and you target subsets with
segments and topics. On MillionSend
Cloud the Free plan holds up to 1,000 contacts (creating one past that returns
403 plan_limit_reached; existing contacts still update); paid plans have no
contact limit.
If you are migrating from Resend: the Resend SDK's contact methods work
against MillionSend whenever audienceId is omitted — the contact paths are
the same, minus the audience nesting.
What a contact holds
email— the identity. Creating a second contact with the same email (any casing) returns409, and creating one at an address whose domain can never receive mail, such asexample.comor.test, returns422(see Errors). A contact already stored at such an address stays, and still updates.first_name,last_nameunsubscribed— the global subscribe state. Unsubscribed contacts are excluded from every broadcast.properties— a flat map of custom string values (plan: "pro",city: "Berlin"). Nested objects and arrays are rejected with422. Properties feed template merge fields and segment filters.PATCH /contacts/{id}mergespropertieskey by key; anullvalue removes that key. The stored map holds at most 100 keys, none of them empty.
Property types
Define a property to give its key a type, in the dashboard under
Audience → Properties or with POST /contact-properties. The type is
string, number or date. string and number work as on Resend, and
date is a MillionSend extension. A date property takes:
- An ISO 8601 date, such as
2026-01-08, read as midnight UTC. - An ISO 8601 date and time, such as
2026-01-08T10:30:00Zor2026-01-08 10:30, read as UTC when it names no zone. - Unix time in seconds or milliseconds, such as
1767225600, between the years 2000 and 2100.
A contact value that doesn't fit its property's type is refused with 422,
and so is a fallback_value that doesn't. Keys match in any letter case. A
date is stored as written, without the spaces around it. GET /contacts/{id}
returns each value as {type, value}, so a date comes back as
{ "type": "date", "value": "2026-01-08" }. A value stored before its key had
a type, and that doesn't fit it, comes back as a string.
You can still keep dates in a string property. Segments and the
warm-up read dates from any property. The
date type checks every write, through the API or the dashboard, and the
warm-up lists date properties first.
API
Contacts are managed via POST/GET/PATCH/DELETE /contacts and
GET /contacts/{id} — see the API reference. The {id}
path segment accepts either the contact UUID or its email address; email
matching is case-insensitive.
Two MillionSend extensions make reading an audience cheap. GET /contacts
and GET /segments/{id}/contacts accept include=properties,topics and
attach to every item the {type, value} property map and the topic rows that
GET /contacts/{id} and GET /contacts/{id}/topics return; without include
the items keep the Resend shape. POST /contacts/batch/get reads up to 1,000
contacts by id or email in one request, in request order, with the same
include; entries that match no contact are listed under missing rather
than failing the call. One call is one request against the rate limit.
Topic subscriptions are set per contact with PATCH /contacts/{id}/topics,
and GET /contacts/{id}/topics reads them back with defaults applied: each
topic's effective subscription, whether it was chosen explicitly, and its
visibility (the hosted page shows public topics only).
POST /contacts/{id}/preferences-link mints the contact's preference-center
URL — { "object": "preferences_link", "contact": "<uuid>", "url": "..." } —
the same page the unsubscribe links in their emails open, so a settings screen
in your product can deep-link into it. The link has no expiry and lets its
holder change that contact's preferences, including the global unsubscribe, so
hand it only to the contact. Also available as the
create_contact_preferences_link MCP tool.
Every change to a contact publishes a
webhook event: contact.created,
contact.updated, contact.deleted, contact.unsubscribed,
contact.resubscribed, contact.topic_opt_in and contact.topic_opt_out,
each with the source that made the change.
Bulk delete
POST /contacts/batch/remove deletes up to 1,000 contacts in one request, by
ids or by emails (exactly one of the two; email matching is
case-insensitive), and returns the rows actually deleted — unknown entries are
skipped. Deleting keeps the contact's emails in the log, where they age out
with the team's retention window; erase: true also scrubs each address from
email history, event payloads and API logs, the same as
DELETE /contacts/{id}?erase=true. Resend has no bulk deletion; this is a
MillionSend extension, also exposed as the delete_contacts MCP tool.
curl -X POST "https://api.millionsend.com/contacts/batch/remove" \
-H "Authorization: Bearer ms_..." \
-H "Content-Type: application/json" \
-d '{ "emails": ["old-list-1@example.com", "old-list-2@example.com"] }'Bulk create
POST /contacts/batch takes a JSON array of 1–1000 items, each shaped like
a POST /contacts body, and writes them in one transaction (a MillionSend
extension — Resend imports contacts only via CSV). The on_conflict query
parameter decides what happens to an item whose email already belongs to a
contact, or repeats inside the batch:
error(default) — the item fails:409 Contact already existsfor an existing contact,422 Duplicate email in batchfor a repeat.skip— the existing contact (or the first occurrence) is left untouched and reported withstatus: "skipped"and its id.upsert— the item is merged into the existing contact:first_nameandlast_nameonly when provided,propertieskey by key (provided keys overwrite),segmentsadded,topicsupserted. Repeats collapse into one write.
A batch never re-subscribes anyone: unsubscribed: true opts the contact
out, but unsubscribed: false on an already unsubscribed contact is ignored
— that stays an explicit PATCH /contacts/{id}. The suppression list is
never touched either.
The x-batch-validation header picks strict (default — the first failing
item rejects the whole batch with its own status and a contacts.{index}:
message prefix, nothing written) or permissive (the valid subset is written
and failures are listed in errors).
curl -X POST "https://api.millionsend.com/contacts/batch?on_conflict=upsert" \
-H "Authorization: Bearer ms_..." \
-H "Content-Type: application/json" \
-H "x-batch-validation: permissive" \
-d '[
{ "email": "ana@example.com", "first_name": "Ana", "properties": { "plan": "pro" } },
{ "email": "not-an-address" }
]'{
"data": [{ "object": "contact", "index": 0, "id": "9b2f…", "status": "updated" }],
"counts": { "created": 0, "updated": 1, "skipped": 0, "failed": 1 },
"errors": [
{
"index": 1,
"statusCode": 422,
"name": "validation_error",
"message": "email: Invalid email address"
}
]
}ana@example.com works here only because the contact already exists: a new
contact at a reserved address like that one is refused.
data keeps request order and lists one entry per successful item (status
created, updated or skipped); counts always sum to the request
length; errors appears only in permissive mode. Each errors entry carries
the statusCode and name strict mode would answer for that item.
CSV import and export
The dashboard imports contacts from CSV (parsed client-side, then created in bulk) and exports the current list — including segment or topic filtered views — back to CSV. Rows with an address that can never receive mail are skipped, like malformed ones.
Unsubscribes
Broadcast emails carry RFC 8058 one-click List-Unsubscribe headers and a
hosted unsubscribe page. A recipient can unsubscribe globally or opt out of
individual topics. Global unsubscribes set
unsubscribed: true on the contact and stop topic sends and broadcasts;
transactional sends without a topic_id still arrive — see
Suppressions.