Email Spam Tester

The API

Three calls. No key, no account, no signup.

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 41 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.

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.

  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.

  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.

  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.

# 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.

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.

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.

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_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 41, 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

Try one by hand first

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

Get a test address