Skip to content
MailMarcodocs

09 · MailMarco API

Marketing

Contacts, consent, segments and broadcasts. The rule language is small on purpose — every field is an enum, every value is a bound parameter.


Contacts

A contact is an email address plus optional profile fields and a bag of tenant-defined properties. Emails are normalised — trimmed and lower-cased — before storage and lookup, so one address is one contact no matter how it was typed.

POST/v1/contacts
auth: API key201 on success

Create a contact.

Body

email
string (email)required
Trimmed, lower-cased, at most 320 characters. Must not contain CR or LF.
firstName
stringoptional
At most 200 characters.
lastName
stringoptional
At most 200 characters.
locale
stringoptional
At most 35 characters, e.g. en-GB.
timezone
stringoptional
At most 64 characters.
source
stringoptional
At most 64 characters. Where the contact came from.
properties
Record<string, string|number|boolean|null>optional
At most 100 keys, keys at most 64 characters, string values at most 1024 characters.
consentStatus
"subscribed" | "unsubscribed" | "pending"optional
Defaults to subscribed on import.
consentSource
stringoptional
At most 128 characters.
consentIp
stringoptional
At most 64 characters.
request
curl -X POST https://api.mailmarco.com/v1/contacts \
  -H "authorization: Bearer $MAILMARCO_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "email": "[email protected]",
    "firstName": "Ada",
    "locale": "en-GB",
    "properties": { "plan": "pro", "seats": 12 },
    "consentStatus": "subscribed",
    "consentSource": "signup-form"
  }'

Errors

  • 400Validation failed — a CR/LF, an over-long value, or too many properties.
  • 409A contact with that normalised email already exists.
GET/v1/contacts
auth: API key200 on success

List contacts. Returns a bare array unless you opt into the envelope.

Query parameters

limit
integeroptionaldefault 25
Between 1 and 100.
offset
integeroptionaldefault 0
Zero-based.
consentStatus
"subscribed" | "unsubscribed" | "pending"optional
Filter by consent state.
search
stringoptional
At most 320 characters.
envelope
booleanoptionaldefault false
See below.
GET/v1/contacts/{id}
auth: API key200 on success

Retrieve one contact. The id must be a UUID or the request is rejected before the handler runs.

Path parameters

id
string (uuid)required
Contact id.
PATCH/v1/contacts/{id}
auth: API key200 on success

Update a contact. Every create field except email is accepted; the email itself is immutable.

Path parameters

id
string (uuid)required
Contact id.
DELETE/v1/contacts/{id}
auth: API key200 on success

Delete a contact.

Path parameters

id
string (uuid)required
Contact id.

CSV and bulk import

POST/v1/contacts/import
auth: API key200 on success

Import up to 10,000 contacts from a JSON array or a raw CSV document.

Body

contacts
CreateContact[]optional
Up to 10,000 rows. Mutually exclusive with `csv`.
csv
stringoptional
A raw CSV document, at most 8 MiB. Mutually exclusive with `contacts`.
upsert
booleanoptionaldefault true
Update an existing contact instead of skipping it.
request
curl -X POST https://api.mailmarco.com/v1/contacts/import \
  -H "authorization: Bearer $MAILMARCO_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "csv": "email,first_name,plan\[email protected],Ada,pro\[email protected],Grace,free\n",
    "upsert": true
  }'
response · 200
{
  "created": 2,
  "updated": 0,
  "skipped": 0,
  "errors": []
}

Errors

  • 400Neither or both of contacts and csv supplied, the CSV has no email column, or more than 10,000 rows.

CSV format

A header row is required and must include an email column. Recognised headers map to contact fields; every unrecognised header becomes a custom property, which is how the plan column above lands in properties.plan.

CSV headerMaps to
emailemail
first_name or firstnamefirstName
last_name or lastnamelastName
localelocale
timezonetimezone
sourcesource
consent_status or consentstatusconsentStatus
anything elseproperties.<header>
  • Header matching is case-insensitive; property keys keep the header's original case.
  • Empty cells are omitted rather than written as empty strings.
  • Rows are validated with the same schema as single creates. A bad row is reported in errors by zero-based index and skipped; the rest still import.
  • Duplicates inside one payload are collapsed before writing, last occurrence wins.
  • Imported contacts default to source: "import" when the column is absent.

GET /v1/contacts/export returns the whole list as CSV (text/csv, as an attachment) with the columns email, first_name, last_name, locale, timezone, source, consent_status, consent_at, bounced, properties. Cells beginning with =, +, - or @ are neutralised so the file cannot execute formulas when opened in a spreadsheet.

The ?envelope=true flag

List routes return a bare JSON array by default, because shipped clients parse them that way. Pass envelope=true (or 1) to get totals instead:

curl
curl "https://api.mailmarco.com/v1/contacts?limit=50&offset=0&envelope=true" \
  -H "authorization: Bearer $MAILMARCO_API_KEY"

# => { "data": [ … ], "total": 1842, "limit": 50, "offset": 0 }

This applies to GET /v1/contacts and GET /v1/broadcasts/:id/recipients. Other list routes have no envelope option.

Topics and double opt-in

A topic is a named subscription — “Product updates”, “Weekly digest”. Contacts subscribe per topic, and segments can filter on topic state.

POST/v1/topics
auth: API key201 on success

Create a topic.

Body

key
stringrequired
Machine key, 1–64 characters matching ^[a-z0-9][a-z0-9_-]*$. Segments reference it as topic.<key>. Immutable after creation.
name
stringrequired
Human label, at most 200 characters.
description
stringoptional
At most 2000 characters.
doubleOptIn
booleanoptionaldefault false
Require an explicit confirmation click before the subscription counts.
request
curl -X POST https://api.mailmarco.com/v1/topics \
  -H "authorization: Bearer $MAILMARCO_API_KEY" \
  -H "content-type: application/json" \
  -d '{"key":"product-updates","name":"Product updates","doubleOptIn":true}'
POST/v1/topics/{id}/subscribe
auth: API key200 on success

Subscribe a contact by id or by email.

Path parameters

id
string (uuid)required
Topic id.

Body

contactId
string (uuid)optional
Either this or `email` is required.
email
string (email)optional
Either this or `contactId` is required.
consentSource
stringoptional
At most 128 characters.
consentIp
stringoptional
At most 64 characters.
response · 200
{
  "subscription": { "status": "pending", "…": "…" },
  "confirmToken": "…"
}

The rest of the topic surface is conventional: GET /v1/topics, GET /v1/topics/:id, PATCH /v1/topics/:id (everything except key), DELETE /v1/topics/:id, and POST /v1/topics/:id/unsubscribe with the same body as subscribe.

Segments

POST/v1/segments
auth: API key201 on success

Create a saved segment from a rule tree.

Body

name
stringrequired
At most 200 characters.
description
stringoptional
At most 2000 characters.
rule
SegmentRulerequired
See the rule language below.
POST/v1/segments/preview
auth: API key200 on success

Dry-run a rule before saving it — send just { rule } and get the matching count and a bounded sample.

request
curl -X POST https://api.mailmarco.com/v1/segments/preview \
  -H "authorization: Bearer $MAILMARCO_API_KEY" \
  -H "content-type: application/json" \
  -d '{"rule":{"field":"consentStatus","cmp":"eq","value":"subscribed"}}'
GET/v1/segments/{id}/preview
auth: API key200 on success

Sample the members of a saved segment.

Path parameters

id
string (uuid)required
Segment id.

Query parameters

limit
integeroptionaldefault 25
Between 1 and 100.

Also available: GET /v1/segments, GET /v1/segments/:id, PATCH /v1/segments/:id and DELETE /v1/segments/:id.

The segment rule language

A rule is either a single condition or a group of rules combined with a boolean operator.

condition
{ "field": "consentStatus", "cmp": "eq", "value": "subscribed" }
group
{
  "op": "and",
  "rules": [
    { "field": "consentStatus", "cmp": "eq",       "value": "subscribed" },
    { "field": "bounced",       "cmp": "eq",       "value": false },
    { "field": "lastActiveAt",  "cmp": "gte",      "value": { "daysAgo": 90 } },
    {
      "op": "or",
      "rules": [
        { "field": "properties.plan", "cmp": "in",  "value": ["pro", "enterprise"] },
        { "field": "topic.product-updates", "cmp": "eq", "value": "subscribed" }
      ]
    }
  ]
}

Allowed fields

field is not free text. It must be one of the contact columns below, or match properties.<key> or topic.<key>. Anything else is rejected before any query is built.

fieldKindValid comparators
email, firstName, lastName, locale, timezone, source, consentStatustexteq, neq, contains, in, gt, gte, lt, lte, exists
bouncedbooleaneq, neq, exists
lastActiveAt, lastSentAt, createdAttimestampeq, neq, gt, gte, lt, lte, exists
properties.<key>JSON property. Key matches [A-Za-z0-9_-]{1,64}.All comparators. Ordering comparators with a numeric value compare numerically; a non-numeric stored value simply does not match.
topic.<key>Topic subscription state. Key matches [a-z0-9][a-z0-9_-]{0,63}.eq, neq, in, exists. The value is a subscription status string and defaults to "subscribed" when omitted.

Comparators and values

cmpValueSemantics
eqscalar or nullEquality. With a null value on a column, tests IS NULL.
neqscalar or nullDistinct-from, so NULLs behave intuitively.
containsstringSubstring match. LIKE metacharacters in your value are escaped, so a value of `%` matches a literal percent sign — not everything.
inarrayMembership. At most 200 entries of strings or numbers. An empty array matches nothing.
gt gte lt ltestring, number or { daysAgo }Ordering. Not valid on a boolean field.
existsomit, or falseField is present/non-null. Pass `false` to invert it.

A date field accepts a relative value: { "daysAgo": 90 } resolves to the instant 90 days before evaluation, with daysAgo between 0 and 36,500. String values are capped at 1024 characters.

Broadcasts

POST/v1/broadcasts
auth: API key201 on success

Create a broadcast in draft.

Body

name
stringrequired
At most 200 characters.
subject
stringrequired
1–998 characters. Must not contain CR or LF.
from
string (email)required
A verified sending domain.
replyTo
string (email)optional
Reply-To header.
html
stringoptional
One of html, text or templateId is required.
text
stringoptional
One of html, text or templateId is required.
templateId
string (uuid)optional
Use a published template instead of inline content.
segmentId
string (uuid)optional
Audience. Mutually exclusive with topicId.
topicId
string (uuid)optional
Audience. Mutually exclusive with segmentId.
scheduledAt
string (date-time)optional
UTC ISO-8601, same format rule as scheduled sends.

Errors

  • 400No content supplied, or both segmentId and topicId given.
RouteStatusDoes
GET /v1/broadcasts200List broadcasts.
GET /v1/broadcasts/{id}200Retrieve one.
PATCH /v1/broadcasts/{id}200Update. segmentId, topicId and scheduledAt accept null to clear them.
DELETE /v1/broadcasts/{id}200Delete.
POST /v1/broadcasts/{id}/send200Dispatch now.
POST /v1/broadcasts/{id}/schedule200Body { "scheduledAt": "…" }.
POST /v1/broadcasts/{id}/cancel200Cancel a scheduled broadcast.
GET /v1/broadcasts/{id}/stats200Aggregate results.
GET /v1/broadcasts/{id}/recipients200Paged with limit (1–200, default 100), offset and envelope.

Broadcast statuses are draft, scheduled, sending, sent, canceled and failed. Note the single L here — unlike a cancelled single message.

Every broadcast message is sent with RFC 8058 one-click unsubscribe headers pointing at a signed token URL:

headers added to each broadcast message
List-Unsubscribe: <https://api.mailmarco.com/p/u/eyJ…>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

Unsubscribe and preference center

The /p/* routes are public and unauthenticated. They are mounted outside /v1 precisely so that is obvious. Every one is gated on an HMAC-signed opaque token and nothing else — no id, email or organization appears in any path, query or body.

RouteDoes
GET /p/u/{token}Renders a confirmation page. Deliberately read-only — a mail scanner prefetching the link must not unsubscribe anyone.
POST /p/u/{token}Performs the unsubscribe. This is the RFC 8058 one-click target and the form target for the page above.
GET /p/confirm/{token}Double opt-in confirmation page.
POST /p/confirm/{token}Confirms the pending subscription.
GET /p/prefs/{token}Reads the contact's topic state.
POST /p/prefs/{token}Updates it. Body: { topics: [{ key, subscribed }], unsubscribeAll? }, at most 200 topics.

The same preference data is available to you authenticated, keyed by contact id: GET /v1/contacts/:id/preferences and PATCH /v1/contacts/:id/preferences, with the same body shape.

Suppression still applies on top of consent — see Deliverability.