Domains
Docs / Control plane

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

Authorization: Bearer spw_…

Requires domains:read.

Keys rule — prefix, shown once, abilities never widen, and no key carries both messages:send and messages:read-content.

Worked example

curl https://spoolway.com/api/v1/domains/1 \
  -H "Authorization: Bearer spw_…"
200 — stale verification
{
  "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

HONESTY NOTE — AN INDICATOR MAY NOT OUTLIVE ITS EVIDENCEstate stays verified when the last successful check was good — but status_label becomes correct at last check — not yet re-verified and check_is_stale is true once the check is older than 24h. Clients must not treat a stale check as a live green tick.

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.

curl
curl -X POST https://spoolway.com/api/v1/dmarc-reports \
  -H "Authorization: Bearer spw_…" \
  -H "Content-Type: application/xml" \
  --data-binary @report.xml
PHP
$ch = curl_init("https://spoolway.com/api/v1/dmarc-reports");
curl_setopt_array($ch, [
  CURLOPT_POST => true,
  CURLOPT_POSTFIELDS => file_get_contents("report.xml"),
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => [
    "Authorization: Bearer " . getenv("SPOOLWAY_KEY"),
    "Content-Type: application/xml",
  ],
]);
$report = json_decode(curl_exec($ch), true);
Node
import { readFile } from "node:fs/promises";

const res = await fetch("https://spoolway.com/api/v1/dmarc-reports", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SPOOLWAY_KEY}`,
    "Content-Type": "application/xml",
  },
  body: await readFile("report.xml"),
});
const report = await res.json();
Python
import os, requests

report = requests.post(
    "https://spoolway.com/api/v1/dmarc-reports",
    data=open("report.xml", "rb").read(),
    headers={
        "Authorization": f"Bearer {os.environ['SPOOLWAY_KEY']}",
        "Content-Type": "application/xml",
    },
).json()
Ruby
require "net/http"; require "json"

uri = URI("https://spoolway.com/api/v1/dmarc-reports")
req = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV["SPOOLWAY_KEY"]}",
  "Content-Type" => "application/xml",
})
req.body = File.binread("report.xml")
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
report = JSON.parse(res.body)
201 Created
{
  "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.

curl
curl "https://spoolway.com/api/v1/dmarc-reports?limit=25" \
  -H "Authorization: Bearer spw_…"
PHP
$ch = curl_init("https://spoolway.com/api/v1/dmarc-reports?limit=25");
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer " . getenv("SPOOLWAY_KEY")],
]);
$page = json_decode(curl_exec($ch), true);
Node
const res = await fetch("https://spoolway.com/api/v1/dmarc-reports?limit=25", {
  headers: { Authorization: `Bearer ${process.env.SPOOLWAY_KEY}` },
});
const page = await res.json();
Python
import os, requests

page = requests.get(
    "https://spoolway.com/api/v1/dmarc-reports",
    params={"limit": 25},
    headers={"Authorization": f"Bearer {os.environ['SPOOLWAY_KEY']}"},
).json()
Ruby
require "net/http"; require "json"

uri = URI("https://spoolway.com/api/v1/dmarc-reports?limit=25")
req = Net::HTTP::Get.new(uri,
  { "Authorization" => "Bearer #{ENV["SPOOLWAY_KEY"]}" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
page = JSON.parse(res.body)
200 list
{
  "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.