MillionSend Docs

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) returns 409.
  • first_name, last_name
  • unsubscribed — 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 with 422. Properties feed template merge fields and segment filters. PATCH /contacts/{id} merges properties key by key; a null value removes that key. The stored map holds at most 100 keys, none of them empty.

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": ["[email protected]", "[email protected]"] }'

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 exists for an existing contact, 422 Duplicate email in batch for a repeat.
  • skip — the existing contact (or the first occurrence) is left untouched and reported with status: "skipped" and its id.
  • upsert — the item is merged into the existing contact: first_name and last_name only when provided, properties key by key (provided keys overwrite), segments added, topics upserted. 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": "[email protected]", "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, "message": "email: Invalid email address" }]
}

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.

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.

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.

On this page