Skip to content
MailMarcodocs

04 · MailMarco API

Domains

A sending domain is five DNS records and one verification call. Until they all resolve, every send from that domain is a 400.


Domain lifecycle

states
  POST /v1/domains          POST /v1/domains/{id}/verify
        │                              │
        ▼                              ▼
     pending ───────────────────► verified ──── POST …/suspend ──► suspended
        │        all required            ▲
        │        records resolve         │  a later verify that passes again
        │                                │
        └── some but not all resolve ──► failed
StatusMeaning
pendingCreated. Either nothing has been published yet, or a verification attempt resolved no records at all — a run where nothing resolves deliberately leaves the domain pending rather than marking it failed.
failedA verification run resolved some records but not every required one. Fix the missing records and verify again.
verifiedEvery required record matched. Sending from this domain is allowed. `verifiedAt` is set once and never overwritten by later runs.
suspendedSuspended via the API or by an operator.
verifyingReserved in the status contract; the API does not emit it today.

Add a domain

POST/v1/domains
auth: Session201 on success

Registers the domain, generates a fresh DKIM keypair, and returns the complete record set to publish.

Body

domain
stringrequired
Trimmed and lower-cased. Must be a dotted name of a-z0-9- labels (1–63 characters each) with no leading or trailing hyphen — mail.example.com is valid, example and -bad.example.com are not.
request
curl -X POST https://api.mailmarco.com/v1/domains \
  -H "authorization: Bearer $MAILMARCO_ACCESS_TOKEN" \
  -H "content-type: application/json" \
  -d '{"domain":"mail.example.com"}'
response · 201
{
  "id": "3c2b1a09-8d7e-4c5b-a6f2-1e0d9c8b7a65",
  "domain": "mail.example.com",
  "status": "pending",
  "region": "eu",
  "verifiedAt": null,
  "createdAt": "2026-07-29T09:16:41.882Z",
  "records": [
    { "id": "…", "type": "SPF",         "host": "mail.example.com",                                "value": "v=spf1 include:spf.mailmarco.com ~all",                                 "ttl": 3600, "verified": false, "checkedAt": null },
    { "id": "…", "type": "DKIM",        "host": "mm20260729a1b2c3._domainkey.mail.example.com",     "value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A…",                   "ttl": 3600, "verified": false, "checkedAt": null },
    { "id": "…", "type": "DMARC",       "host": "_dmarc.mail.example.com",                          "value": "v=DMARC1; p=quarantine; rua=mailto:[email protected]; fo=1",  "ttl": 3600, "verified": false, "checkedAt": null },
    { "id": "…", "type": "RETURN_PATH", "host": "bounce.mail.example.com",                          "value": "bounce.mailmarco.com",                                                  "ttl": 3600, "verified": false, "checkedAt": null },
    { "id": "…", "type": "TRACKING",    "host": "track.mail.example.com",                           "value": "track.mailmarco.com",                                                   "ttl": 3600, "verified": false, "checkedAt": null }
  ]
}

Errors

  • 400The domain does not match the accepted grammar.
  • 409The domain is already registered.

The generated DNS records

All five are required — verification only succeeds when every one of them resolves.

TypeRRHostWhat it does
SPFTXTexample.comAuthorises MailMarco's sending IPs via include:spf.mailmarco.com. Published with ~all (softfail) so an unrelated sender is marked, not hard-rejected. If you already have an SPF record on that name, merge the include into it — a domain must publish exactly one SPF record.
DKIMTXT<selector>._domainkey.example.comPublishes the RSA public key for the keypair minted for this domain. This is the cryptographic proof of ownership and the record that survives forwarding — the one to get right first.
DMARCTXT_dmarc.example.comPolicy p=quarantine with fo=1, and aggregate reports sent to a MailMarco-operated mailbox so failures are actually monitored. Tighten to p=reject yourself once you are confident.
RETURN_PATHCNAMEbounce.example.comAligns the envelope return-path with your domain so bounces are attributable and SPF aligns. Points at bounce.mailmarco.com, where the MX that receives bounce mail actually lives.
TRACKINGCNAMEtrack.example.comServes open pixels and click redirects from your own hostname instead of a shared one. See Tracking.

As a zone file

zone
; substitute your own selector and public key from the API response
mail.example.com.                            3600 IN TXT   "v=spf1 include:spf.mailmarco.com ~all"
mm20260729a1b2c3._domainkey.mail.example.com. 3600 IN TXT   "v=DKIM1; k=rsa; p=MIIBIjANBgkq…"
_dmarc.mail.example.com.                     3600 IN TXT   "v=DMARC1; p=quarantine; rua=mailto:[email protected]; fo=1"
bounce.mail.example.com.                     3600 IN CNAME bounce.mailmarco.com.
track.mail.example.com.                      3600 IN CNAME track.mailmarco.com.

Verification

POST/v1/domains/{id}/verify
auth: Session201 on success

Resolves every record over live DNS, stores each result, and moves the domain status accordingly.

Path parameters

id
string (uuid)required
Domain id.
request
curl -X POST https://api.mailmarco.com/v1/domains/$DOMAIN_ID/verify \
  -H "authorization: Bearer $MAILMARCO_ACCESS_TOKEN"
response · 201
{
  "id": "3c2b1a09-8d7e-4c5b-a6f2-1e0d9c8b7a65",
  "domain": "mail.example.com",
  "status": "verified",
  "region": "eu",
  "verifiedAt": "2026-07-29T10:02:55.410Z",
  "createdAt": "2026-07-29T09:16:41.882Z",
  "records": [
    { "id": "…", "type": "SPF", "host": "mail.example.com", "value": "v=spf1 include:spf.mailmarco.com ~all", "ttl": 3600, "verified": true, "checkedAt": "2026-07-29T10:02:55.402Z" }
  ]
}

Errors

  • 404No domain with that id in your organization.
  • 429More than 10 verification calls for this organization in an hour. The body carries retryAfterSeconds.

How each record is checked

  • TXT — every answer's chunks are joined back together and compared for an exact match against the expected value. A record split across strings by your provider is fine; a modified value is not.
  • CNAME — compared case-insensitively, ignoring a trailing dot.
  • Failures are not distinguished. NXDOMAIN, NODATA and SERVFAIL all read as “not published yet”, so a broken resolver looks the same as an unpublished record.

A verification run that flips the domain into verified for the first time emits a domain.verified event, which you can subscribe to with a webhook. Re-verifying an already-verified domain does not emit it again.

Suspending a domain

POST/v1/domains/{id}/suspend
auth: Session201 on success

Marks the domain suspended. Sends from it are refused with 400 until it is verified again.

Path parameters

id
string (uuid)required
Domain id.
response · 201
{
  "id": "3c2b1a09-8d7e-4c5b-a6f2-1e0d9c8b7a65",
  "domain": "mail.example.com",
  "status": "suspended",
  "region": "eu",
  "verifiedAt": "2026-07-29T10:02:55.410Z",
  "createdAt": "2026-07-29T09:16:41.882Z"
}

Errors

  • 404No domain with that id in your organization.

Endpoints

GET/v1/domains
auth: Session200 on success

All domains in the organization, without their DNS records.

GET/v1/domains/{id}
auth: Session200 on success

One domain, with its DNS records and their latest check results.

Path parameters

id
string (uuid)required
Domain id.

Errors

  • 404No domain with that id in your organization.
GET/v1/domains/{id}/dns-records
auth: Session200 on success

Just the records — the array you would render in a setup wizard.

Path parameters

id
string (uuid)required
Domain id.
response · 200
[
  {
    "id": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
    "type": "DKIM",
    "host": "mm20260729a1b2c3._domainkey.mail.example.com",
    "value": "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A…",
    "ttl": 3600,
    "verified": true,
    "checkedAt": "2026-07-29T10:02:55.402Z"
  }
]

Responses never include the organization id or any DKIM private-key material — the DTO mappers strip both before serialisation.