Try it in the 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.
The loop
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.
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.
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.
# 1. reserve a single-use address curl -sX POST 'https://email-spam-tester.com/api/v1/inbox?lang=en' { "address": "test-<slug>@t.email-spam-tester.com", "slug": "<slug>", "expires_at": "2026-01-01T12:00:00Z" }
# 2. send your message to that address, then poll curl -s 'https://email-spam-tester.com/api/v1/tests/<slug>/status' { "slug": "<slug>", "analysis_status": "checks_ready", "ai_status": "running", "checks_done": 39, "checks_total": 39 }
# 3. read the report once analysis_status is checks_ready curl -s 'https://email-spam-tester.com/api/v1/tests/<slug>' { "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"}} ] }
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.
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.
GET/api/health
Liveness. No authentication, no side effects.
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 41, 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.
Try one by hand first
It takes about a minute and shows you exactly what the JSON describes.