# API

The [app](https://sendscore.ai/docs/self-hosting/) 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:

```bash
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

```bash
curl -s -X POST -H "Authorization: Bearer $SENDSCORE_TOKEN" \
  https://sendscore.example.com/api/v1/test-addresses
```

It 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

```bash
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:

```json
{ "status": "received", "report_id": "rpt_...", "report_url": "https://sendscore.example.com/r/rpt_...", "verdict": { } }
```

```json
{ "status": "not_received" }
```

`verdict` is [the verdict JSON](https://sendscore.ai/docs/verdict/) 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:

```bash
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

```bash
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](https://sendscore.ai/docs/self-hosting/#configuration) 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](https://sendscore.ai/docs/monitoring/) gives each newsletter its own address. Two requests read them.

```bash
curl -s -H "Authorization: Bearer $SENDSCORE_TOKEN" https://sendscore.example.com/api/v1/publications
```

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

```bash
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](https://sendscore.ai/docs/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](https://sendscore.ai/docs/mcp/) instead, which wrap these requests.
