Skip to content
MailMarcodocs

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

POST/v1/emails
auth: API key202 on success

Validates, persists and enqueues one message. 202 means accepted, not delivered — the outcome arrives as a webhook event.

Headers

Idempotency-Key
stringoptional
Optional. Replaying the same key for the same organization returns the original message with deduped: true.
request
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" }
  }'
response · 202
{
  "id": "b1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
  "status": "queued"
}

Errors

  • 400Validation failed, the from domain is unknown or unverified, a recipient is suppressed, or an attachment failed the malware scan.
  • 401Missing or invalid API key.
  • 402The plan send limit is reached (error: plan_limit_exceeded).
  • 403The organization is paused (error: org_suspended).
  • 429The warm-up daily cap is reached (error: warmup_cap_reached).

Request body

FieldTypeNotes
fromstring (email) · requiredThe domain part must be a domain your organization owns and has verified.
tostring | string[] · requiredA single address, or a non-empty array of addresses.
ccstring[]Additional visible recipients.
bccstring[]Additional hidden recipients.
subjectstringRequired unless template is supplied, in which case the published version's subject is used. Minimum length 1. Must not contain CR or LF.
htmlstringHTML body.
textstringPlain-text body. Send both where you can.
replyTostring (email)Sets the Reply-To header.
template{ id, variables }id is a template UUID; variables defaults to {}. See Templates.
attachmentsAttachment[]At most 20 attachments, 10 MiB each and 25 MiB in total after base64 decoding.
headersRecord<string, string>Custom RFC 5322 headers. Names must match ^[A-Za-z0-9-]+$; values must not contain CR or LF.
tagsRecord<string, string>Stored on the message and returned in listings. String values only.
metadataRecord<string, unknown>Arbitrary JSON stored alongside the message.
sandboxbooleanPersist and simulate the send without touching the delivery queue.
scheduledAtstring (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 deduped field.
  • 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.
response · 202 (replay)
{
  "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
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"}
POST/v1/emails/{id}/cancel
auth: API key201 on success

Cancels a message that is still scheduled. Answers 201, the framework default for POST — not 200.

Path parameters

id
string (uuid)required
Message id.
request
curl -X POST https://api.mailmarco.com/v1/emails/$MESSAGE_ID/cancel \
  -H "authorization: Bearer $MAILMARCO_API_KEY"
response · 201
{
  "id": "b1d2e3f4-5a6b-4c7d-8e9f-0a1b2c3d4e5f",
  "status": "cancelled"
}

Errors

  • 404No message with that id in your organization.
  • 409The message is not scheduled — 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
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 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
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.

FieldTypeNotes
filenamestring · requiredAt least 1 character. Must not contain CR or LF.
contentTypestring · requiredAt least 1 character, e.g. application/pdf.
contentstring · requiredBase64-encoded bytes. Decoded size must be ≤ 10 MiB.
inlinebooleanMarks the part as inline rather than an attachment.
contentIdstringContent-ID for referencing an inline part from HTML.
curl
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 400 without 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 400 and the message is not created.
  • The object key is generated server-side; your filename is kept only as metadata.

Custom headers, tags and metadata

body fragment
{
  "headers": { "X-Entity-Ref-Id": "order-2291", "List-Id": "receipts.example.com" },
  "tags": { "kind": "receipt", "tier": "pro" },
  "metadata": { "orderId": 2291, "lineItems": 3 }
}
  • headers become real RFC 5322 headers on the outgoing message. Names must match ^[A-Za-z0-9-]+$; values are rejected if they contain CR or LF.
  • tags are a flat string-to-string map, meant for filtering and reporting.
  • metadata accepts 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.

body fragment
{ "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

GET/v1/emails/{id}
auth: API key200 on success

The message with its recipients and full event timeline.

Path parameters

id
string (uuid)required
Message id.
response · 200
{
  "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

  • 404No message with that id in your organization.

Statuses

A message moves through these values:

status
queued · scheduled · sent · delivered · delivery_delayed
bounced · complained · opened · clicked · failed · cancelled

Listing 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

StatusMessageCause
400domain example.com not found for this orgThe from domain has not been added to this organization.
400domain example.com is not verifiedThe domain exists but its required DNS records have not all resolved yet.
400recipient suppressed: [email protected]The address — or its domain — is on your suppression list. See Deliverability.
400attachment x.pdf rejected: …The malware scan did not return clean.
400Validation failedA schema violation. The details array names the offending path.
402Plan limit reached for emails_sentThe monthly plan allowance is exhausted.
403organization sending is pausedThe organization is suspended, automatically or by an operator.
429warm-up daily send cap reachedToday's warm-up allowance is used up. Retry tomorrow.