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"}]
}Pick the one you need
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.
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" }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.comPoll 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.
| lang | Language 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_campaign | Passed 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.
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.
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
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"} ] } }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>, ...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
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'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"} ] } }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)'
| domain | Required. Takes what the tools take: a domain, a URL, an email address. |
| ip | Your sending address. Repeat it for more, up to 6. |
| selector | A DKIM selector. Repeat it for more, up to 10. Every selector you pass has to exist, or the dkim section fails. |
| helo | The name your server gives in HELO, compared with its PTR. |
| account | Who the request is for. Required with a key that serves many people. |
| checks | Sections to run, comma separated. All of them by default. |
| diff | true compares this run with your last run of the domain. |
| since | 7d, 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. |
| baseline | The id of one of your runs to compare with. Turns diff on. |
| fresh | true 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
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'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
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.
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_ours | 0 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_compat | 0 to 10. Reproduces the SpamAssassin-style number people already compare against, so the report is comparable to what somebody saw elsewhere. |
| subscores | The 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[].citations | Where 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. |
| complete | False when a check could not run. An unchecked item is never a pass, so treat the score as optimistic until it is true. |
| report_url | The 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.
| pass | The check ran and found nothing wrong. |
| warn | Worth fixing. Costs points. |
| fail | Will cost you delivery. Costs more points. |
| skip | Did not apply. No attachments to scan, no HTML part to weigh. Not a pass. |
| error | We 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
| 202 | The 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. |
| 404 | No such slug. Never existed, or the reservation was forgotten. |
| 410 | The address expired before a message arrived. |
| 409 | You tried to change the language after the message had already arrived. |
| 429 | The 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
- /api/openapi.json The OpenAPI description, generated from the same code that serves the API.
- /llms.txt, /llms-full.txt A short map of this site for language models, in the llms.txt convention, and a longer version with the whole loop and the report fields in one file.
- /mcp An MCP server with the same capability and a blocking wait, which beats polling from inside a model.
- For agents The agent page: when to reach for this, how to read a skipped check against a passing one, and which findings are DNS records a person has to change rather than text an agent can edit.
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 addressSandbox.
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