Domains
Read a sending domain, and forward the DMARC reports mailbox providers send about it. GET /v1/domains, GET /v1/domains/{id}, GET /v1/dmarc-reports and POST /v1/dmarc-reports. All four use domains:read. Creating or editing a domain stays in the panel.
Authentication
Requires domains:read.
Keys rule — prefix, shown once, abilities never widen, and no key carries both messages:send and messages:read-content.
Worked example
{
"data": {
"id": 1,
"name": "stale.example",
"state": "verified",
"status_label": "correct at last check — not yet re-verified",
"check_is_stale": true,
"verified_at": "2026-07-07T08:11:01+00:00",
"failed_at": null,
"last_checked_at": "2026-08-03T08:11:01+00:00",
"records": [
{
"id": 1,
"type": "TXT",
"purpose": "OWNERSHIP",
"name": "_spoolway.stale.example",
"expected": "spoolway-verify=token",
"observed": "spoolway-verify=token",
"state": "ok",
"checked_at": "2026-08-03T08:11:01+00:00",
"verified_at": "2026-07-07T08:11:01+00:00"
}
]
}
}State and staleness
failed_at is when the ownership check last failed; the next check that passes clears it. verified_at is when it last passed, and a failure does not clear it. While the failure is the later of the two, state is failing. Both are ISO 8601 timestamps, or null.
Responses & failures
There is no API that creates or edits a sending domain. 403 without domains:read. A cross-account id is 404. An empty DMARC body is 422 empty_report. A body over 5 MB is 413 report_too_large. A file the parser cannot read is 422 unreadable_report. A list cursor that is an integer id, or not a position in the list, is 422 invalid_cursor.
DMARC reports
We read your _dmarc record daily and show the policy on the Domains screen, but that is advisory — it is your DNS, not ours. The reports themselves are the only view of your deliverability that does not come from us: mailbox providers send them, and they say what happened to mail claiming to be from your domain.
Receivers mail those reports to the address in your rua= tag. We take no inbound mail, so point rua= at a mailbox you own and forward what arrives to POST /v1/dmarc-reports with a key holding domains:read. The body is the report file itself — gzip, zip or plain XML — or a multipart file field named report. Sending the same report id again replaces the stored report and returns 200.
PHP
Node
Python
Ruby
{
"id": 1,
"domain": "example.test",
"org_name": "google.com",
"rows": 2,
"messages": 45,
"passed": 42,
"failed": 3,
"replaced": false
}Sending the same report id again returns 200. The body has the same fields, with replaced set to true.
GET /v1/dmarc-reports uses the same ability. domain is optional and matched in lower case. limit is 1 to 100 and defaults to 25. Rows are ordered by range_end descending, then id descending. next_cursor is an opaque string naming both the range_end and the id of the last row, and the next page is every row that sorts after that pair. Pass it back unchanged. A bare integer — the previous cursor, which was an id — is refused with 422 invalid_cursor, because an id alone cannot resume this order. Start again without a cursor.
PHP
Node
Python
Ruby
{
"data": [
{
"id": 1,
"domain": "example.test",
"org_name": "google.com",
"report_id": "18446744073709551615",
"range_begin": "2025-08-30T00:00:00+00:00",
"range_end": "2025-08-31T00:00:00+00:00",
"policy_p": "none",
"messages": 45,
"passed": 42,
"failed": 3
}
],
"next_cursor": null
}A report about a domain nobody has added here is kept, not discarded — that is how somebody learns they are being spoofed. DMARC passes when either SPF or DKIM aligns, so a row failing one mechanism is not necessarily a failure.