Email Spam Tester

Spam check API for email,
inbox and domain

Send us one real message, or give us only a domain. Everything answers in JSON.

No key, no account.

Request

# reserve a single-use address

$ curl -sX POST '.../api/v1/inbox?lang=en'

# send your message to that address, then

$ curl -s '.../api/v1/tests/k7m2x9qplw4tz8vn3b6c/status'

# read the report

$ curl -s '.../api/v1/tests/k7m2x9qplw4tz8vn3b6c'

Response

200 OK

{
  "address": "test-k7m2x9qplw4tz8vn3b6c@t.email-spam-tester.com",
  "slug": "k7m2x9qplw4tz8vn3b6c",
  "expires_at": "2026-10-31T13:00:00Z"
}

200 OK

{
  "analysis_status": "checks_ready",
  "ai_status": "running",
  "checks_done": 42,
  "checks_total": 42
}

200 OK

{
  "score_ours": 86.4,
  "score_compat": 9.2,
  "subscores": {"auth": 100.0, "infra_spam": 78.1, "content": 92.0},
  "complete": true,
  "checks": [{"id": "auth.dmarc", "status": "warn"}],
  "fixes": [{"id": "dmarc-enforce", "gain_ours": 4.0}]
}

Request

# create a test

$ curl -sX POST '.../api/v1/placement' \ -d '{"public_results_accepted":true}'

# put the marker in the subject, send once to all, then

$ curl -s '.../api/v1/placement/wR3kP9xLq2vT8mZc'

Response

201 Created

{
  "marker": "est-3f9a1c7e2b4d6a80c5e1f2d3",
  "status": "waiting",
  "results": [{"provider": "gmail", "placement": "pending"}],
  "fleet": {"status": "observing", "results": [
    {"provider": "yahoo", "placement": "pending"},
    {"provider": "outlook", "placement": "pending"}
  ]}
}

200 OK

{
  "status": "complete",
  "results": [{"provider": "gmail", "placement": "primary"}],
  "fleet": {"status": "finished", "results": [
    {"provider": "yahoo", "placement": "inbox"},
    {"provider": "outlook", "placement": "spam"}
  ]}
}

Request

# no email needed, one GET

$ curl -s '.../api/v1/infra/check?domain=example.com'

# compare with your last run

$ curl -s '.../api/v1/infra/check?domain=example.com&diff=true' \ | jq '.diff'

Response

200 OK

{
  "domain": "example.com",
  "ok": false,
  "status": "fail",
  "score": 78,
  "coverage": {"answered": 7, "total": 8},
  "sections": {"ptr": {"status": "fail", "core": true}}
}

200 OK

{
  "changed": true,
  "worsened": true,
  "status_from": "pass",
  "score_from": 100,
  "changes": [{"path": "dkim.key.mail.sha256", "kind": "removed",
               "severity": "critical", "effect": "worsened"}]
}
On this page

Email test

We read one real message end to end: content, signature, headers and the server that sent it. You get a score, a list of fixes and every check behind them.

Quick start

Three calls. No key, no account, no signup. Two requests and one email. The third call is only there because the analysis takes a few seconds.

  1. Reserve an address

    POST to the inbox endpoint. You get back an address that accepts exactly one message and expires in an hour, plus the slug everything else is keyed on.

    Request

    # 1. reserve a single-use address
    curl -sX POST 'https://email-spam-tester.com/api/v1/inbox?lang=en'

    Response

    {
      "address": "test-<slug>@t.email-spam-tester.com",
      "slug": "<slug>",
      "expires_at": "2026-01-01T12:00:00Z"
    }
  2. Send the real message to it

    Over SMTP, from the platform that will send the campaign. Half the checks read headers your sending platform adds, so a message composed by hand measures the wrong thing.

    Request

    # 2. send your message to that address, from your own platform
    To: test-<slug>@t.email-spam-tester.com
  3. Poll the status, then read the report

    The status endpoint is a few hundred bytes and tells you whether the full report is worth fetching yet. The report itself runs to several thousand lines.

    Request

    # 3. poll
    curl -s 'https://email-spam-tester.com/api/v1/tests/<slug>/status'

    Response

    {
      "slug": "<slug>",
      "analysis_status": "checks_ready",
      "ai_status": "running",
      "checks_done": 42,
      "checks_total": 42
    }

    Request

    # 4. read the report once analysis_status is checks_ready
    curl -s 'https://email-spam-tester.com/api/v1/tests/<slug>'

    Response

    {
      "report_url": "https://email-spam-tester.com/t/<slug>",
      "score_ours": 86.4,
      "score_compat": 9.2,
      "subscores": {"auth": 100.0, "infra_spam": 78.1, "content": 92.0, "compliance": 75.0},
      "complete": true,
      "checks": [
        {
          "id": "auth.dmarc",
          "status": "warn",
          "title": "DMARC result",
          "summary": "DMARC passes, but the policy is p=none.",
          "weight_ours": -4.0,
          "evidence": {"policy": "none", "aligned": "dkim"},
          "citations": {"standards": [{"title": "RFC 9989 §4.7", "quote": "..."}]}
        }
      ],
      "fixes": [
        {"id": "dmarc-enforce", "gain_ours": 4.0, "gain_compat": 0.0,
         "fix": {"title": "Move DMARC to quarantine", "severity": "medium"}}
      ]
    }

Reserve an address, send the message you were about to send your list, read the report. Everything the page shows is in the JSON, including the score, all 42 checks with their evidence, and the section of the standard each finding rests on.

The first 100,000 reports are free, with no key and no account. If you are wiring up an agent rather than a script, the MCP server is a better fit than polling.

Endpoints

Base address https://email-spam-tester.com. Everything answers JSON.

POST/api/v1/inbox

Reserves a single-use address. Both parameters are optional.

langLanguage the fix plan and the per-check findings will be written in, as an ISO code. Decided now because the message arrives over SMTP minutes later and carries no hint of who is waiting for it. An unknown code falls back to English.
utm_source, utm_medium, utm_campaignPassed through to our analytics. Useful if you are measuring your own integration.

GET/api/v1/tests/{slug}/status

Cheap to poll. Returns the two status fields and how far the checks have got, and nothing else.

Poll this rather than the report. analysis_status reaches checks_ready first, and the AI plan lands under ai_status a minute or so later, so a caller that only wants the deterministic checks does not have to wait for the plan.

GET/api/v1/tests/{slug}

The whole report. Answers 202 while the address is reserved and nothing has arrived yet, 200 once it has.

GET/api/v1/tests/{slug}/message

The message exactly as it was delivered: every header in wire order, both bodies, the attachment list and the raw source. Fetched separately because the report is polled and this can run to a megabyte.

The raw message is deleted on its own clock, sooner than the report. After that this endpoint answers with the reason rather than the source.

PUT/api/v1/tests/{slug}/locale

Changes the language of a reservation that has not been used yet. For an agent that learns which language its user reads after it has already asked for the address. Refused once the message has arrived, because by then the analysis is running.

Inbox placementBeta

Send one message to a list of test inboxes and see where it landed at Gmail, Outlook, Yahoo and other providers: Inbox, Promotions or Spam.

Quick start

  1. Create a test

    POST to the placement endpoint. You get back a marker and the test inboxes.

    Request

    # 1. create a test
    curl -sX POST 'https://email-spam-tester.com/api/v1/placement' \
      -H 'Content-Type: application/json' \
      -d '{"public_results_accepted":true}'

    Response

    {
      "slug": "<slug>",
      "marker": "est-3f9a1c7e2b4d6a80c5e1f2d3",
      "status": "waiting",
      "deadline": "2026-10-31T12:30:00Z",
      "poll_after_seconds": 60,
      "results": [
        {"address": "<seed>", "provider": "gmail", "kind": "Gmail",
         "placement": "pending", "observed_at": null}
      ],
      "fleet": {
        "status": "observing",
        "results": [
          {"address": "<mailbox>", "provider": "yahoo", "available": true,
           "placement": "pending", "coverage": "pending"}
        ]
      }
    }
  2. Send one message to every inbox

    Put the marker in the subject and send from your normal platform, to all the addresses in one go.

    Request

    # 2. one message to every address, with the marker in the subject
    Subject: October invoice est-3f9a1c7e2b4d6a80c5e1f2d3
    To: <seed>, <mailbox>, ...
  3. Poll for the folders

    Each address fills in with its provider and folder. Poll until the status stops being waiting.

    Request

    # 3. poll for the folders
    curl -s 'https://email-spam-tester.com/api/v1/placement/<slug>'

    Response

    {
      "status": "complete",
      "received": 2,
      "total": 2,
      "results": [
        {"address": "<seed>", "provider": "gmail", "kind": "Gmail",
         "placement": "primary", "observed_at": "2026-10-31T12:04:11Z"},
        {"address": "<seed>", "provider": "gmail", "kind": "Google Workspace",
         "placement": "promotions", "observed_at": "2026-10-31T12:04:11Z"}
      ],
      "fleet": {
        "status": "finished",
        "results": [
          {"address": "<mailbox>", "provider": "yahoo", "available": true,
           "placement": "inbox", "coverage": "ok"},
          {"address": "<mailbox>", "provider": "outlook", "available": true,
           "placement": "spam", "coverage": "ok"}
        ]
      }
    }

Endpoints

An optional test using public GMass seed inboxes and available project test mailboxes, alone or alongside the email score. Explain to the sender that GMass receives their email and publishes its subject, sender name and placement before creating a test. Use all test mailboxes for non-confidential test messages only.

GET /api/v1/placement/availability

Returns providers: the provider identifiers of currently healthy, admitted project mailboxes. No addresses or credentials are exposed. This read does not create a test or contact GMass. Actual recipients are allocated at creation and may differ as availability or capacity changes.

POST /api/v1/placement

Send JSON {"public_results_accepted":true}. Returns HTTP 201 with slug, marker, results (the seed addresses), deadline and expires_at. Limit: 20 tests per IP per UTC day.

For one-send combined testing, first reserve an email score address and include its score_slug in the placement creation JSON. Repeated creation for that score returns the same placement test. Send the marked email to the score address, every returned seed and every available fleet.results address; keep the main /t/<score_slug> report link. Creating a link after the score address was used or expired returns HTTP 409.

GET /api/v1/placement/by-score/{score_slug}

Read and refresh the placement run attached to the main email test. The response has the same format and polling limits as the standalone endpoint below. HTTP 404 means placement was not included or its stored results have been removed.

GET /api/v1/placement/{slug}

Use poll_after_seconds (normally 60 seconds) while the page is open. Upstream errors trigger a shared cooldown. Add the marker to the subject and send through the sender's normal platform to every seed. Observations are saved for 30 days. Collection ends after 30 minutes or when all seeds have results.

status: waiting, complete or timed_out. Each result has an address, provider, kind, placement and observed_at. Placement is primary, promotions, inbox, updates, social, forums, spam, unknown, pending or not_observed. Missing results do not mean Spam; Promotions is an inbox category. provider_error indicates an upstream outage without discarding saved observations. HTTP 404 means unknown, 410 expired, 429 quota exhausted, and 503 no test inboxes available at creation.

The optional fleet object contains independently collected project mailbox results, with status observing, settling or finished. Refresh every 30 seconds until it finishes, even when Gmail has completed. Each fleet result includes address, provider, available, placement, category and coverage. Exclude unavailable addresses from new sends; retain their saved observations. Category data may be unavailable even when the physical folder is known.

If GMass is unavailable at creation but project mailboxes can be allocated, provider is fleet and the top-level seed results are empty. Use fleet.results; do not interpret an empty Gmail list as successful delivery. Existing runs never gain new recipient addresses silently.

Infrastructure check

One GET checks the mail setup of a domain without sending anything, keeps the result, and tells you what changed since your last run. No key needed. In cron, the line below exits non-zero when the domain is broken or its setup changed in a way that matters.

Quick start

  1. Make one GET

    Pass the domain. ip adds your sending addresses, and selector names the DKIM selectors to read.

    Request

    curl -s 'https://email-spam-tester.com/api/v1/infra/check?domain=example.com&selector=mail&diff=true'
  2. Read the verdict

    ok is the answer for a script, and sections shows which part failed.

    Response

    {
      "id": "ir_01JB8Y4T2M6K9QZ7H3V5N1XWDA",
      "domain": "example.com",
      "checked_at": "2026-10-31T12:00:00Z",
      "ok": false,
      "status": "fail",
      "score": 78,
      "coverage": {"answered": 7, "total": 8},
      "ips": [{"ip": "203.0.113.5", "sources": ["mx", "spf"], "managed": false}],
      "sections": {
        "dkim": {"status": "pass", "core": true, "findings": [], "data": {}},
        "ptr": {"status": "fail", "core": true, "findings": [], "data": {}}
      },
      "not_checked": [
        {"check": "dkim_signature", "reason": "needs_a_message"},
        {"check": "alignment", "reason": "needs_a_message"}
      ],
      "diff": {
        "baseline": {"id": "ir_01JB2...", "checked_at": "2026-10-24T12:00:00Z"},
        "first_run": false,
        "changed": true,
        "worsened": true,
        "status_from": "pass",
        "score_from": 100,
        "changes": [
          {"path": "dkim.key.mail.sha256", "section": "dkim", "kind": "removed",
           "severity": "critical", "effect": "worsened", "from": "9f2c...", "to": null}
        ],
        "not_compared": [
          {"path": "blocklist_ip.203.0.113.5.bl.spamcop.net.listed", "reason": "lookup_failed"}
        ]
      }
    }
  3. Put it in cron

    With diff=true each run is compared with your last one, and jq turns the answer into an exit code.

    Request

    # exits non-zero if the domain is broken or its setup changed
    curl -s 'https://email-spam-tester.com/api/v1/infra/check?domain=example.com&selector=mail&diff=true' \
      | jq -e '.ok and (.diff.changed | not)'
domainRequired. Takes what the tools take: a domain, a URL, an email address.
ipYour sending address. Repeat it for more, up to 6.
selectorA DKIM selector. Repeat it for more, up to 10. Every selector you pass has to exist, or the dkim section fails.
heloThe name your server gives in HELO, compared with its PTR.
accountWho the request is for. Required with a key that serves many people.
checksSections to run, comma separated. All of them by default.
difftrue compares this run with your last run of the domain.
since7d, 12h or an ISO 8601 date. Compares with your run closest to that moment within 12 hours, otherwise the last one before it. Turns diff on.
baselineThe id of one of your runs to compare with. Turns diff on.
freshtrue skips the 60-second cache.

Reading the answer

A check that finished answers 200, and the verdict is in the body. status is the worst of the required sections: spf, dkim, dmarc, ptr, blocklist_ip, blocklist_domain, mx and smtp. unknown in a required section counts as warn. ok is true when the status is pass or warn. score runs from 0 to 100 over the required sections that answered, and is null when errors and unknowns leave less than half of the weight that applies. mta_sts, tls_rpt, bimi, dnssec, dane, ns and registration are reported and compared, but they do not touch ok or score.

A section can be pass, warn, fail, error (we could not check), unknown (too little to go on) or skipped (does not apply). skipped has no effect on the result, and the reason is in not_checked. dkim_signature and alignment are always there, because they need a real message.

What ip and selector change

PTR and blocklists are about addresses, so a run needs some. It uses the ip values you pass. With none, it takes up to four from your MX hosts and from single-address ip4: and ip6: terms in the root SPF record. Addresses of a known mail provider are listed as managed and left unchecked, and with nothing left ptr and blocklist_ip answer skipped. A selector cannot be read from DNS, so a run tries yours, those your earlier runs found, and a list of common names. Finding nothing proves nothing, and dkim answers unknown. If you send from your own server, pass both.

What changed

Each entry in diff.changes has a path, a kind (added, removed, changed), a severity (critical, warning, info) and an effect (worsened, improved, neutral). Only critical and warning set diff.changed, so a rotated DKIM key of the same length, a new MX address or a renewed certificate does not make the cron line fail. diff.worsened follows the effect of any change. On a first run there is nothing to compare, and diff.first_run says so.

A baseline is always a run of your own, so somebody else checking the domain between two of your runs hides nothing. Anything we could not read this time goes to diff.not_compared with its last known value kept, which means a change made during an outage shows up once the lookup works again. A run with a different ip or selector stays in the same series, and what it did not ask about is not compared either.

Why a scheduled check should use a key

Without a key your history belongs to a keyed hash of your IP address, so it ends when the address changes, and people behind one address share it: a colleague's run moves your baseline. With a personal API key in Authorization: Bearer, the history follows the key. Runs with a key are kept 365 days and open only with that key. Runs without one are kept 30 days and open to anyone who has their id.

6 requests a minute and 50 a day per IP, or 30 and 500 per key. Above that the answer is 429 with Retry-After. An identical request within 60 seconds comes from a cache, is marked cached: true, writes no run and does not count. Reading stored runs allows 120 requests a minute per client. 503 with Retry-After means too many checks are running, or, when reading stored runs, that storage is unavailable. 401 is a wrong key. 422 carries detail.code: invalid_domain, invalid_ip, invalid_selector, invalid_check, invalid_since, unknown_baseline or account_required.

Endpoints

GET/api/v1/infra/check

Runs the check. Parameters are in the table above.

GET/api/v1/infra/runs/{public_id}

One stored run, whole. 404 if it does not exist or belongs to another key.

GET/api/v1/infra/domains/{domain}/runs

Your runs of a domain, newest first: id, time, status, score and number of changes. Takes limit (1 to 100, 30 by default) and before, a run id.

GET/api/v1/infra/diff

The same comparison between two stored runs, ?from=<id>&to=<id>. You need access to both.

GET/api/v1/infra/checks

Every section with its weight, the score version and the limits.

Single checks

Each call answers one question and keeps no history. A tool runs one part of the infrastructure check on its own, except the header tool, which reads a message you already received.

Quick start

  1. Pick a check

    Choose the tool from the list below and pass the domain, the address or the IP it asks for.

    Request

    curl -s 'https://email-spam-tester.com/api/v1/tools/spf?domain=example.com'
  2. Read status and findings

    Every tool answers with a status and its findings. The fields after them depend on the tool.

    Response

    {
      "tool": "spf",
      "query": {"domain": "example.com"},
      "checked_at": "2026-10-31T12:00:00Z",
      "duration_ms": 412,
      "status": "fail",
      "findings": [
        {"code": "spf.too_many_lookups", "level": "fail",
         "params": {"limit": 10, "count": 12},
         "message": "RFC 7208 §4.6.4 allows at most 10 DNS lookups, and this record needs 12, so receivers return permerror."}
      ],
      "record": "v=spf1 include:_spf.example.net ~all",
      "lookups": {"count": 12, "limit": 10, "void": 0}
    }

Endpoints

All of them answer 200 with a status and a list of findings, each with a code and its parameters. The status is pass, warn, fail or error, and a tool shared with the infrastructure check can also answer unknown or skipped. A lookup that timed out or a list that refused the query never counts as a pass. 30 requests a minute and 300 a day per IP across the tools; above that the answer is 429 with Retry-After.

GET/api/v1/tools/spf

The SPF record of a domain: its syntax, every include and redirect followed, and the DNS lookups the whole tree costs against the limit of ten. Pass ?domain=example.com.

GET/api/v1/tools/dmarc

The DMARC record of a domain, or of its organisational domain when it has none: the tags, the policy and whether each external report address has agreed to take reports. Pass ?domain=example.com.

GET/api/v1/tools/dkim

The DKIM key published for a selector: whether it exists and decodes, its type and size, and the revoked and testing flags. Pass ?domain=example.com&selector=s1.

GET/api/v1/tools/blocklist

An IPv4 address, an IPv6 address or a domain checked against the DNS blocklists the report uses, with the answer of each list. Pass ?target=203.0.113.5.

GET/api/v1/tools/mx

The MX records of a domain in the order senders try them, with the IPv4 and IPv6 addresses of every host. Null MX, a domain with no MX, hosts that are IP addresses or CNAMEs and hosts that do not resolve are reported, and so are the MTA-STS and TLS-RPT records. Pass ?domain=example.com.

GET/api/v1/tools/bimi

The BIMI setup of a domain in four steps: the DMARC policy BIMI requires, the record at default._bimi, the SVG logo checked against the Tiny PS profile, and the mark certificate with its type, domains and expiry. Pass ?domain=example.com, and &selector= for a selector other than default.

GET/api/v1/tools/ptr

The PTR records of an address and whether any of those names resolves back to it. A lookup that failed gives the status error; a missing record gives the status fail. Pass ?ip=8.8.8.8.

GET/api/v1/tools/mta-sts

The MTA-STS record of a domain and the policy file it points to: the mode, the hosts listed, and any MX host the policy leaves out. Pass ?domain=example.com.

GET/api/v1/tools/tls-rpt

The TLS-RPT record of a domain and the addresses that receive its reports. Pass ?domain=example.com.

GET/api/v1/tools/dnssec

Whether the zone of a domain is signed, unsigned or broken, from its DNSKEY and DS records. A bad signature is told apart from a resolver outage. Pass ?domain=example.com.

GET/api/v1/tools/dane

The TLSA records of the MX hosts, compared with the certificate a host presents when that certificate could be read. Pass ?domain=example.com.

GET/api/v1/tools/smtp

STARTTLS and the certificate on the first two MX hosts: TLS version, issuer, expiry date, and whether the chain and the name are valid. We connect only to hosts under the domain you asked about and skip mail providers, and one result per address is shared for 6 hours. No mail is sent. Pass ?domain=example.com.

GET/api/v1/tools/ns

The name servers of a domain and whether its zone has a SOA record. One name server is a warning. Pass ?domain=example.com.

GET/api/v1/tools/registration

When the registration of a domain runs out, from RDAP. Under 30 days left is a warning. A zone with no RDAP service answers skipped. Pass ?domain=example.com.

POST/api/v1/tools/header

The headers of a received message, sent as JSON {"headers": "..."} or as text/plain: the route hop by hop with delays, TLS and clock skew, the receiver's own Authentication-Results, the alignment of the From, DKIM d= and Return-Path domains, and the DKIM, ARC, unsubscribe and spam filter headers. Everything after the first empty line is dropped unread. The headers are processed in memory and not stored, cached or logged.

Check the domain every night.
Test the email before it goes.

Shared by every product

Access, keys, the report format, statuses, errors, plus OpenAPI and llms.txt for machines.

Authentication

There is none. No key to request, no header to set, no account to create. The slug you get back is the capability: whoever holds it can read that report, and nobody else can guess it.

That also means a slug is worth treating like a password. A report shows the subject line, the sender, the bounce address and the full source of the message to anyone holding the link.

Personal API keys

Everything above works without a key. A key is for automation platforms such as Zapier, where every user's calls come from the platform's servers and per-IP limits would be shared by all of them. With a key, limits count per key (50 tests a day, 3 with placement, 5 permanent mailboxes), the key's tests form a history, and the key can keep permanent mailboxes whose reports only it can read. Send the key as Authorization: Bearer est_live_… on any call on this page. The routes below also take account, a short name of your choosing ([A-Za-z0-9_-], up to 64 characters) that keeps separate lists under one key. GET /api/v1/installations/tests requires it; pass the same one on POST /api/v1/inbox so the test shows up there. The Zapier integration uses zapier.

POST /api/v1/keys

Send JSON {"email":"you@example.com"}. Returns HTTP 201 with key and email. The key is shown once; only its hash is kept. Asking again gives a new key and leaves the earlier ones working. Limit: 5 keys per IP per UTC day. The form on /zapier/ calls this.

GET /api/v1/keys/me

Returns email, key_prefix and created_at of the key in the header, or HTTP 401. Use it to check a key.

GET /api/v1/installations/tests

The key's tests, newest first: ?account=…&limit=50 (up to 100), and before=<slug> for the next page. Each item has slug, created_at, analysis_status (waiting or expired before an email arrived), score_ours, score_compat, placement and changes, the checks that moved since the previous test of the same sending domain. Read the full report at /api/v1/tests/{slug}.

POST /api/v1/installations/mailboxes

Send JSON {"domain":"example.com","account":"…"}. Returns the permanent mailbox of that sending domain: id, address, domain, state, daily_cap and created. HTTP 201 when it is new, 200 when it already existed, 409 when the key keeps as many mailboxes as it may. Subscribe the address to a newsletter or a sequence and every email that arrives is checked. GET on the same path with ?account=… lists them, and DELETE /api/v1/installations/mailboxes/{mailbox_id} stops one.

GET /api/v1/installations/mailboxes/{mailbox_id}/letters

The emails that arrived, oldest first, paged with after_id and next_after_id. settled=true ends the page before the first email still being checked, so paging never skips one. newest=true turns the order round, for a quick look at the latest; settled does nothing with it. Each has test_id, slug, received_at, analysis_status, score_ours, score_compat, subject, from_addr and kind.

GET /api/v1/installations/letters/{slug}

The report of one email in a permanent mailbox, the same JSON as /api/v1/tests/{slug} with report_url null: there is no public page for it. HTTP 410 once the report has expired.

Reading the report

The fields that carry the answer, in the order you probably want them.

score_ours0 to 100. Our model. Authentication and infrastructure carry most of the weight, because they decide delivery before a filter reads a word of the copy.
score_compat0 to 10. Reproduces the SpamAssassin-style number people already compare against, so the report is comparable to what somebody saw elsewhere.
subscoresThe same 0 to 100 scale per section: auth, infra_spam, content, compliance.
checks[]All 42, each with a status, a one-line finding, the evidence it was decided on and what it cost the two scores.
checks[].citationsWhere the finding comes from: the section of the RFC, quoted verbatim, and the page where Google states its own requirement. Curated by hand rather than generated, so they can be relied on.
fixes[]What to change, in order, with the points each change is worth. The gains are computed by re-scoring the report, not estimated.
completeFalse when a check could not run. An unchecked item is never a pass, so treat the score as optimistic until it is true.
report_urlThe human-readable page. Hand that to a person rather than a wall of JSON.

Statuses

A check is one of five things, and the difference between two of them matters more than it looks.

passThe check ran and found nothing wrong.
warnWorth fixing. Costs points.
failWill cost you delivery. Costs more points.
skipDid not apply. No attachments to scan, no HTML part to weigh. Not a pass.
errorWe could not find out. Excluded from the score and flagged, rather than counted as a pass.

analysis_status runs received, analyzing, checks_ready, failed. ai_status runs pending, running, ready, fallback, error. The deterministic report is final at checks_ready; ai_status only ever adds the plan.

What can come back

202The address is reserved and no message has arrived. The body carries the address, the expiry and the language, so a caller that did not create the reservation can still show it.
404No such slug. Never existed, or the reservation was forgotten.
410The address expired before a message arrived.
409You tried to change the language after the message had already arrived.
429The free-test limit for your address. There is no per-person limit on this service right now; if one is ever set, the body says when it resets.

Four things worth knowing

One message per address

The second one is refused. Your earlier report stays intact, which is the point.

An hour to use it

The address expires; the report does not. Report links are permanent, so one pasted into a ticket keeps working.

Send from the platform

Testing a campaign from a personal account measures that account. Almost everything in the authentication section is about the sending infrastructure, not the text.

Re-test after fixing

The expected gains are computed one fix at a time and they do not compose. The second report is the honest number.

For machines

GET/api/health

Liveness. No authentication, no side effects.

Try one by hand first

It takes about a minute and shows you exactly what the JSON describes.

Get a test address

Sandbox.

Every endpoint, with a button that runs it against the live service. Nothing here needs a key, so a request you fire from this page is a real one: it reserves a real address and it counts.

Try it in the sandbox