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, and creating one at an address whose domain can never receive mail, such as example.com or .test, returns 422 (see Errors). A contact already stored at such an address stays, and still updates.
  • 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.

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:00Z or 2026-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 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": "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.

On this page