API
Copy MarkdownAPI
The app serves a small JSON API, with one request for a test address, one that waits for the message, and one for a report. It serves them at your app’s SENDSCORE_BASE_URL, written here as https://sendscore.example.com.
Authentication
Every /api/v1/ request needs an API token. Sign in at your app’s /signin page with your email address. There is no password, because the app emails you a sign-in link. Then create a token on the Account page. It starts with ss_live_ and is shown once, so copy it then. The Account page lists each token’s prefix, when it was created, and when it was last used, and a token can be revoked there at any time.
Send the token in the Authorization header:
export SENDSCORE_TOKEN=ss_live_...
curl -s -H "Authorization: Bearer $SENDSCORE_TOKEN" https://sendscore.example.com/api/v1/reports/<report_id>A missing, unknown, or revoked token answers 401. Each account sees only its own test addresses and reports, and asking for another account’s answers 404.
Get a test address
curl -s -X POST -H "Authorization: Bearer $SENDSCORE_TOKEN" \
https://sendscore.example.com/api/v1/test-addressesIt answers 201 with:
| Field | What it holds |
|---|---|
address |
Where to send the preview, t-<token>@ the mail domain |
token |
The address’s token, for the wait request |
expires_at |
When the address stops accepting mail, 24 hours after it was made |
wait_url |
The wait request’s URL |
page_url |
A page that shows the report when the message arrives |
Wait for the message
curl -s -H "Authorization: Bearer $SENDSCORE_TOKEN" \
"https://sendscore.example.com/api/v1/test-addresses/<token>/wait?timeout=30"This long-polls until a message reaches the address or the timeout passes. timeout is in seconds, 30 by default and at most 50, so call it again to wait longer. It answers 200 either way:
{ "status": "received", "report_id": "rpt_...", "report_url": "https://sendscore.example.com/r/rpt_...", "verdict": { } }{ "status": "not_received" }verdict is the verdict JSON in full.
Waiting keeps working after the address stops accepting mail, for as long as its reports are kept. Once the address has expired with no matching message, the wait answers 410 at once, so get a new address. An unknown address token, one whose reports were deleted, or one made by another account answers 404. An account can hold 5 waits open at once, and another answers 429 until one finishes.
It returns the newest message at the address, even one that arrived before the call. To check a second send to the same address, for example after a fix, add the earlier report’s id as after, and only a newer message counts:
curl -s -H "Authorization: Bearer $SENDSCORE_TOKEN" \
"https://sendscore.example.com/api/v1/test-addresses/<token>/wait?timeout=30&after=<report_id>"An after that is not a report from this address answers 400.
Each message is checked for one test address. A message sent to two test addresses in one SMTP transaction is accepted for the first, and the second gets a 452, so the platform delivers it again separately.
The receiver also limits how much mail it takes, and answers with an SMTP code a platform understands.
| Limit | Reply past it |
|---|---|
| 20 messages to one test address, over its life | 550 at RCPT |
| 1000 messages a UTC day to all of an account’s test addresses together | 452 at RCPT, so the platform tries again later |
| 10 messages in one SMTP connection | 451 at MAIL, so the platform reconnects |
| 50 connections open at once, and 5 from one sending IP | 421 |
Read a report
curl -s -H "Authorization: Bearer $SENDSCORE_TOKEN" \
https://sendscore.example.com/api/v1/reports/<report_id>It answers with the verdict JSON, or 404 when there is no such report or it belongs to another account.
A report also has a page at its report_url, https://sendscore.example.com/r/<report_id>, and its JSON is at https://sendscore.example.com/r/<report_id>/json. Neither needs a token. The id is long and unguessable, so anyone with the link can read the report, and nobody without it can find it. The original message is shown only to the account that owns it, or for an anonymous test address or upload, to the browser that made it.
Daily limits
An account gets 500 test addresses a day, counted per UTC day, through the API, MCP, and the app’s pages while signed in, and its test addresses receive up to 1000 messages a day together. Past that, POST /api/v1/test-addresses answers 429, and the error says the allowance resets at midnight UTC. Self-hosting lists the settings that change them. Waiting and reading reports do not count. Visitors who are not signed in can get 5 test addresses a day per IP from the app’s home and upload pages, with uploads counted in the same 5.
Uploads
The app’s upload page checks a saved .eml file instead of a delivered message. An upload made while signed in belongs to the account, so GET /api/v1/reports/<report_id> and get_report return it. Its report says it came from an upload, and marks the sending IP and envelope sender as given by the uploader, since the app did not see the delivery. SPF and reverse DNS are checked only when the sending IP is given, and TLS is never checked for an upload.
Monitored newsletters
Monitoring gives each newsletter its own address. Two requests read them.
curl -s -H "Authorization: Bearer $SENDSCORE_TOKEN" https://sendscore.example.com/api/v1/publicationsIt answers {"publications": [...]}, one entry for each newsletter the account monitors.
| Field | What it holds |
|---|---|
id |
The newsletter’s id, starting with pub_ |
name |
Its name |
address |
Its monitoring address, m-<token>@ the mail domain |
schedule |
days (such as ["monday", "thursday"]), time as HH:MM, time_zone, grace_minutes, and description, a sentence saying the schedule. It is null when no schedule is set. |
alert_email |
Where alerts go |
paused |
true when alerts are paused |
page_url |
The newsletter’s page in the app |
latest |
The latest issue, or null before the first one |
last_alert |
The last alert sent, with kind (worse, missed, or duplicate), sent_at, subject, and report_id when it was about an issue. It is null before the first alert. |
curl -s -H "Authorization: Bearer $SENDSCORE_TOKEN" https://sendscore.example.com/api/v1/publications/<id>It answers with the same fields for one newsletter, plus reports, its 50 newest messages. Each one in reports, like latest, has these fields.
| Field | What it holds |
|---|---|
report_id |
The report’s id, for GET /api/v1/reports/<report_id> |
kind |
issue, or signup for a confirmation or welcome email, which is not compared |
received_at |
When it arrived |
subject |
Its subject |
verdict |
The overall verdict, pass, warn, or fail |
ready_to_send |
The verdict’s ready_to_send |
changes |
How many changes it had from the issue before it |
worsened |
true when one of those changes made a check worse |
late |
true when it arrived after the schedule’s grace period |
report_url |
The report’s page |
latest is always an issue, never a signup message. An id that is not one of the account’s newsletters answers 404.
Errors
An error answers with a status code and {"error": "..."}, a sentence saying what went wrong.
How long things last
A test address accepts mail for 24 hours. Reports from test addresses and uploads are kept for 7 days, and a monitored newsletter’s for 365 days. The original of a message is kept for 30 days. To compare an issue with one from longer ago, save both as .eml files and use sendscore diff from the CLI. A message or upload can be at most 25 MB, and a larger one is refused.
From an agent
An agent that speaks MCP can use the MCP tools instead, which wrap these requests.