03 · MailMarco API
Emails
One endpoint sends everything — inline HTML, a published template, an attachment-heavy receipt, or a message scheduled for next Tuesday.
Send an email
/v1/emailsValidates, persists and enqueues one message. 202 means accepted, not delivered — the outcome arrives as a webhook event.
Headers
Idempotency-Keystringoptional | Optional. Replaying the same key for the same organization returns the original message with deduped: true. |
curl -X POST https://api.mailmarco.com/v1/emails \
-H "authorization: Bearer $MAILMARCO_API_KEY" \
-H "content-type: application/json" \
-d '{
"from": "[email protected]",
"to": ["[email protected]"],
"subject": "Your receipt",
"html": "<p>Thanks for your order.</p>",
"text": "Thanks for your order.",
"tags": { "kind": "receipt" }
}'{
"id": "b1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"status": "queued"
}Errors
400— Validation failed, thefromdomain is unknown or unverified, a recipient is suppressed, or an attachment failed the malware scan.401— Missing or invalid API key.402— The plan send limit is reached (error: plan_limit_exceeded).403— The organization is paused (error: org_suspended).429— The warm-up daily cap is reached (error: warmup_cap_reached).
Request body
| Field | Type | Notes |
|---|---|---|
from | string (email) · required | The domain part must be a domain your organization owns and has verified. |
to | string | string[] · required | A single address, or a non-empty array of addresses. |
cc | string[] | Additional visible recipients. |
bcc | string[] | Additional hidden recipients. |
subject | string | Required unless template is supplied, in which case the published version's subject is used. Minimum length 1. Must not contain CR or LF. |
html | string | HTML body. |
text | string | Plain-text body. Send both where you can. |
replyTo | string (email) | Sets the Reply-To header. |
template | { id, variables } | id is a template UUID; variables defaults to {}. See Templates. |
attachments | Attachment[] | At most 20 attachments, 10 MiB each and 25 MiB in total after base64 decoding. |
headers | Record<string, string> | Custom RFC 5322 headers. Names must match ^[A-Za-z0-9-]+$; values must not contain CR or LF. |
tags | Record<string, string> | Stored on the message and returned in listings. String values only. |
metadata | Record<string, unknown> | Arbitrary JSON stored alongside the message. |
sandbox | boolean | Persist and simulate the send without touching the delivery queue. |
scheduledAt | string (date-time) | A UTC ISO-8601 instant. See Scheduling for the exact format accepted. |
Idempotency
Pass an Idempotency-Key header to make a send safe to retry. The key is scoped to your organization and to the whole message — not to a recipient.
- First request with a given key: the message is created normally and the response has no
dedupedfield. - Any later request with the same key: the original message id and status come back with
deduped: true. Nothing is re-sent, re-queued or re-metered. - Two concurrent requests with the same key: exactly one wins the insert. The loser looks the winner up and returns it as deduped. No 409 is raised.
- The body is not compared. Reusing a key with different content returns the original message; it does not send the new content and does not error.
{
"id": "b1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"status": "queued",
"deduped": true
}Scheduling and cancellation
Supplying scheduledAt stores the message with status scheduled and keeps it out of the delivery queue until the scheduler promotes it. Suppression, domain and plan checks all still run at request time; the organization's paused state is re-checked again at dispatch, so a suspension between scheduling and send time still stops the message.
curl -X POST https://api.mailmarco.com/v1/emails \
-H "authorization: Bearer $MAILMARCO_API_KEY" \
-H "content-type: application/json" \
-d '{
"from": "[email protected]",
"to": "[email protected]",
"subject": "Tuesday digest",
"text": "Here is what happened.",
"scheduledAt": "2026-08-04T07:00:00Z"
}'
# => 202 {"id":"…","status":"scheduled"}/v1/emails/{id}/cancelCancels a message that is still scheduled. Answers 201, the framework default for POST — not 200.
Path parameters
idstring (uuid)required | Message id. |
curl -X POST https://api.mailmarco.com/v1/emails/$MESSAGE_ID/cancel \
-H "authorization: Bearer $MAILMARCO_API_KEY"{
"id": "b1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"status": "cancelled"
}Errors
404— No message with that id in your organization.409— The message is notscheduled— a queued or already-sent message cannot be recalled.
Batch send
POST /v1/emails/batch takes 1–500 independent messages. Each one goes through the full single-send path, so templates, attachments and scheduling all work inside a batch.
curl -X POST https://api.mailmarco.com/v1/emails/batch \
-H "authorization: Bearer $MAILMARCO_API_KEY" \
-H "content-type: application/json" \
-d '{
"emails": [
{ "from": "[email protected]", "to": "[email protected]", "subject": "Hi A", "text": "…" },
{ "from": "[email protected]", "to": "[email protected]", "subject": "Hi B", "text": "…" }
]
}'{
"results": [
{ "index": 0, "id": "2f9a…", "status": "queued" },
{ "index": 1, "error": "recipient suppressed: [email protected]" }
]
}Results are aligned to the request order by index. A successful item carries id and status (and deduped when applicable); a failed item carries only error, a human-readable message.
Templates
Swap subject/html/text for a template object. MailMarco renders the template's published version with your variables, HTML-escaping every interpolated value.
curl -X POST https://api.mailmarco.com/v1/emails \
-H "authorization: Bearer $MAILMARCO_API_KEY" \
-H "content-type: application/json" \
-d '{
"from": "[email protected]",
"to": "[email protected]",
"template": {
"id": "7d3c9b21-4a5e-4f6a-8b7c-0d1e2f3a4b5c",
"variables": { "name": "Ada", "total": "€42.00" }
}
}'Rendering happens before anything is written, so an unknown or unpublished template fails fast with no partial message. You may still send subject alongside a template — it is validated but the published version's subject is what gets used.
Attachments
Attachments are base64 in the JSON body. There is no multipart upload endpoint.
| Field | Type | Notes |
|---|---|---|
filename | string · required | At least 1 character. Must not contain CR or LF. |
contentType | string · required | At least 1 character, e.g. application/pdf. |
content | string · required | Base64-encoded bytes. Decoded size must be ≤ 10 MiB. |
inline | boolean | Marks the part as inline rather than an attachment. |
contentId | string | Content-ID for referencing an inline part from HTML. |
curl -X POST https://api.mailmarco.com/v1/emails \
-H "authorization: Bearer $MAILMARCO_API_KEY" \
-H "content-type: application/json" \
-d "{
\"from\": \"[email protected]\",
\"to\": \"[email protected]\",
\"subject\": \"Your invoice\",
\"text\": \"Invoice attached.\",
\"attachments\": [{
\"filename\": \"invoice.pdf\",
\"contentType\": \"application/pdf\",
\"content\": \"$(base64 -w0 invoice.pdf)\"
}]
}"- Counts and sizes are checked before decoding. The decoded size is computed from the base64 length, so an oversized payload is a
400without ever being buffered. - Limits: at most 20 attachments; 10 MiB decoded per attachment; 25 MiB decoded in total.
- Every attachment is malware-scanned before the message is queued. An infected file rejects the entire send with
400and the message is not created. - The object key is generated server-side; your
filenameis kept only as metadata.
Custom headers, tags and metadata
{
"headers": { "X-Entity-Ref-Id": "order-2291", "List-Id": "receipts.example.com" },
"tags": { "kind": "receipt", "tier": "pro" },
"metadata": { "orderId": 2291, "lineItems": 3 }
}headersbecome real RFC 5322 headers on the outgoing message. Names must match^[A-Za-z0-9-]+$; values are rejected if they contain CR or LF.tagsare a flat string-to-string map, meant for filtering and reporting.metadataaccepts arbitrary JSON values and is echoed back on the message; it never reaches the recipient.
Sandbox mode
Set sandbox: true to exercise the whole request path — validation, domain checks, suppression checks, template rendering, attachment scanning, quota metering — without handing the message to the delivery queue.
{ "from": "[email protected]", "to": "[email protected]", "subject": "Smoke test", "text": "…", "sandbox": true }A sandbox message is persisted, is visible via GET /v1/emails/:id with sandbox: true, and records two events: queued, then a simulated sent whose providerResponse is "sandbox: not delivered". Nothing is transmitted.
Retrieve a message
/v1/emails/{id}The message with its recipients and full event timeline.
Path parameters
idstring (uuid)required | Message id. |
{
"id": "b1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
"status": "delivered",
"from": "[email protected]",
"subject": "Your receipt",
"sandbox": false,
"createdAt": "2026-07-29T09:22:10.004Z",
"recipients": [
{ "type": "to", "address": "[email protected]", "status": "delivered" }
],
"events": [
{ "type": "queued", "occurredAt": "2026-07-29T09:22:10.004Z", "providerResponse": null },
{ "type": "sent", "occurredAt": "2026-07-29T09:22:11.551Z", "providerResponse": "250 OK" },
{ "type": "delivered", "occurredAt": "2026-07-29T09:22:12.907Z", "providerResponse": "250 2.0.0" }
]
}Errors
404— No message with that id in your organization.
Statuses
A message moves through these values:
queued · scheduled · sent · delivered · delivery_delayed
bounced · complained · opened · clicked · failed · cancelledListing messages is a dashboard route: GET /v1/emails?limit=&cursor=&status= takes a session token, not an API key, and returns a cursor-paginated { data, nextCursor } envelope with limit between 1 and 100 (default 25). The same is true of GET /v1/logs and GET /v1/analytics?range=30d.
Why a send gets rejected
| Status | Message | Cause |
|---|---|---|
400 | domain example.com not found for this org | The from domain has not been added to this organization. |
400 | domain example.com is not verified | The domain exists but its required DNS records have not all resolved yet. |
400 | recipient suppressed: [email protected] | The address — or its domain — is on your suppression list. See Deliverability. |
400 | attachment x.pdf rejected: … | The malware scan did not return clean. |
400 | Validation failed | A schema violation. The details array names the offending path. |
402 | Plan limit reached for emails_sent | The monthly plan allowance is exhausted. |
403 | organization sending is paused | The organization is suspended, automatically or by an operator. |
429 | warm-up daily send cap reached | Today's warm-up allowance is used up. Retry tomorrow. |