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.
/v1/contactsCreate a contact.
Body
emailstring (email)required | Trimmed, lower-cased, at most 320 characters. Must not contain CR or LF. |
firstNamestringoptional | At most 200 characters. |
lastNamestringoptional | At most 200 characters. |
localestringoptional | At most 35 characters, e.g. en-GB. |
timezonestringoptional | At most 64 characters. |
sourcestringoptional | At most 64 characters. Where the contact came from. |
propertiesRecord<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. |
consentSourcestringoptional | At most 128 characters. |
consentIpstringoptional | At most 64 characters. |
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
400— Validation failed — a CR/LF, an over-long value, or too many properties.409— A contact with that normalised email already exists.
/v1/contactsList contacts. Returns a bare array unless you opt into the envelope.
Query parameters
limitintegeroptionaldefault 25 | Between 1 and 100. |
offsetintegeroptionaldefault 0 | Zero-based. |
consentStatus"subscribed" | "unsubscribed" | "pending"optional | Filter by consent state. |
searchstringoptional | At most 320 characters. |
envelopebooleanoptionaldefault false | See below. |
/v1/contacts/{id}Retrieve one contact. The id must be a UUID or the request is rejected before the handler runs.
Path parameters
idstring (uuid)required | Contact id. |
/v1/contacts/{id}Update a contact. Every create field except email is accepted; the email itself is immutable.
Path parameters
idstring (uuid)required | Contact id. |
/v1/contacts/{id}Delete a contact.
Path parameters
idstring (uuid)required | Contact id. |
CSV and bulk import
/v1/contacts/importImport up to 10,000 contacts from a JSON array or a raw CSV document.
Body
contactsCreateContact[]optional | Up to 10,000 rows. Mutually exclusive with `csv`. |
csvstringoptional | A raw CSV document, at most 8 MiB. Mutually exclusive with `contacts`. |
upsertbooleanoptionaldefault true | Update an existing contact instead of skipping it. |
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
}'{
"created": 2,
"updated": 0,
"skipped": 0,
"errors": []
}Errors
400— Neither or both ofcontactsandcsvsupplied, the CSV has noemailcolumn, 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 header | Maps to |
|---|---|
email | email |
first_name or firstname | firstName |
last_name or lastname | lastName |
locale | locale |
timezone | timezone |
source | source |
consent_status or consentstatus | consentStatus |
| anything else | properties.<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
errorsby 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 "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.
/v1/topicsCreate a topic.
Body
keystringrequired | Machine key, 1–64 characters matching ^[a-z0-9][a-z0-9_-]*$. Segments reference it as topic.<key>. Immutable after creation. |
namestringrequired | Human label, at most 200 characters. |
descriptionstringoptional | At most 2000 characters. |
doubleOptInbooleanoptionaldefault false | Require an explicit confirmation click before the subscription counts. |
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}'/v1/topics/{id}/subscribeSubscribe a contact by id or by email.
Path parameters
idstring (uuid)required | Topic id. |
Body
contactIdstring (uuid)optional | Either this or `email` is required. |
emailstring (email)optional | Either this or `contactId` is required. |
consentSourcestringoptional | At most 128 characters. |
consentIpstringoptional | At most 64 characters. |
{
"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
/v1/segmentsCreate a saved segment from a rule tree.
Body
namestringrequired | At most 200 characters. |
descriptionstringoptional | At most 2000 characters. |
ruleSegmentRulerequired | See the rule language below. |
/v1/segments/previewDry-run a rule before saving it — send just { rule } and get the matching count and a bounded sample.
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"}}'/v1/segments/{id}/previewSample the members of a saved segment.
Path parameters
idstring (uuid)required | Segment id. |
Query parameters
limitintegeroptionaldefault 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.
{ "field": "consentStatus", "cmp": "eq", "value": "subscribed" }{
"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.
| field | Kind | Valid comparators |
|---|---|---|
email, firstName, lastName, locale, timezone, source, consentStatus | text | eq, neq, contains, in, gt, gte, lt, lte, exists |
bounced | boolean | eq, neq, exists |
lastActiveAt, lastSentAt, createdAt | timestamp | eq, 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
| cmp | Value | Semantics |
|---|---|---|
eq | scalar or null | Equality. With a null value on a column, tests IS NULL. |
neq | scalar or null | Distinct-from, so NULLs behave intuitively. |
contains | string | Substring match. LIKE metacharacters in your value are escaped, so a value of `%` matches a literal percent sign — not everything. |
in | array | Membership. At most 200 entries of strings or numbers. An empty array matches nothing. |
gt gte lt lte | string, number or { daysAgo } | Ordering. Not valid on a boolean field. |
exists | omit, or false | Field 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
/v1/broadcastsCreate a broadcast in draft.
Body
namestringrequired | At most 200 characters. |
subjectstringrequired | 1–998 characters. Must not contain CR or LF. |
fromstring (email)required | A verified sending domain. |
replyTostring (email)optional | Reply-To header. |
htmlstringoptional | One of html, text or templateId is required. |
textstringoptional | One of html, text or templateId is required. |
templateIdstring (uuid)optional | Use a published template instead of inline content. |
segmentIdstring (uuid)optional | Audience. Mutually exclusive with topicId. |
topicIdstring (uuid)optional | Audience. Mutually exclusive with segmentId. |
scheduledAtstring (date-time)optional | UTC ISO-8601, same format rule as scheduled sends. |
Errors
400— No content supplied, or bothsegmentIdandtopicIdgiven.
| Route | Status | Does |
|---|---|---|
GET /v1/broadcasts | 200 | List broadcasts. |
GET /v1/broadcasts/{id} | 200 | Retrieve one. |
PATCH /v1/broadcasts/{id} | 200 | Update. segmentId, topicId and scheduledAt accept null to clear them. |
DELETE /v1/broadcasts/{id} | 200 | Delete. |
POST /v1/broadcasts/{id}/send | 200 | Dispatch now. |
POST /v1/broadcasts/{id}/schedule | 200 | Body { "scheduledAt": "…" }. |
POST /v1/broadcasts/{id}/cancel | 200 | Cancel a scheduled broadcast. |
GET /v1/broadcasts/{id}/stats | 200 | Aggregate results. |
GET /v1/broadcasts/{id}/recipients | 200 | Paged 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:
List-Unsubscribe: <https://api.mailmarco.com/p/u/eyJ…>
List-Unsubscribe-Post: List-Unsubscribe=One-ClickUnsubscribe 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.
| Route | Does |
|---|---|
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.