---
title: "REST API | Proxy Mail QA"
description: "Proxy Mail QA's REST API for tests and scripts: create a disposable address, then wait for its verification code or link in one call."
url: https://proxymail.qa/docs/rest-api
---

# REST API

Base URL `https://api.proxymail.qa/v1`, with `Authorization: Bearer qa_…`. Every endpoint is in the [REST API reference](https://proxymail.qa/docs/reference/), generated from the [OpenAPI description](https://api.proxymail.qa/v1/openapi.json).

Using JavaScript or TypeScript? The [proxymail-qa](https://proxymail.qa/docs/node) package on npm wraps these calls, with types.

## Quick start

Use an [API key](https://proxymail.qa/docs/api-keys) from the project you’re testing.

Terminal window

```bash
# Create an address with a persona and a password
curl -X POST https://api.proxymail.qa/v1/addresses \
  -H "Authorization: Bearer $PROXYMAIL_QA_API_KEY" -H "Content-Type: application/json" \
  -d '{"label": "Sign-up test", "persona": true, "password": true}'

# ...sign up in your app with it, then wait for the code
curl "https://api.proxymail.qa/v1/addresses/k7f2x9ab12cd@mailclub.dev/code" \
  -H "Authorization: Bearer $PROXYMAIL_QA_API_KEY"
```

The code comes back on its own:

```json
{
  "type": "code",
  "subtype": "otp",
  "value": "986512",
  "service": "Spotify",
  "received_at": "2026-10-12T10:00:03Z",
  "expires_at": "2026-10-12T10:10:03Z"
}
```

## Endpoints

| Method and path                                                               | What it does                                                                                                                                                                                                                                                   |
| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| GET /v1/project                                                               | The key’s project, its mail domains and limits                                                                                                                                                                                                                 |
| POST /v1/project/dev-hosts                                                    | Mark a host such as localhost:3000 as a dev environment (host), so the email checks pass links to it                                                                                                                                                           |
| POST /v1/addresses                                                            | Create an address (label, name, persona, password, lifetime\_days, domain, age or date\_of\_birth, country, and where it’s used: site and environment)                                                                                                         |
| POST /v1/form-values                                                          | What to type or pick in each field of a sign-up form, from a new address made for it or one made earlier ([more](https://proxymail.qa/docs/rest-api#values-for-a-sign-up-form))                                                                                |
| GET /v1/addresses                                                             | List addresses, newest first, or find ones made earlier (tag, country, min\_age, max\_age, has\_password, search, site, environment)                                                                                                                           |
| GET / PATCH / DELETE /v1/addresses/{address}                                  | Read, update (first\_name, last\_name, username, date\_of\_birth, country, label, notes, tags), or delete one                                                                                                                                                  |
| GET /v1/addresses/{address}/code                                              | Wait for a code (timeout, service, since; unused=true skips codes already used)                                                                                                                                                                                |
| GET /v1/addresses/{address}/link                                              | Wait for a link (type: verify, magic\_signin, invite, reset or any; app=true for a link that opens a mobile app)                                                                                                                                               |
| GET /v1/addresses/{address}/count                                             | Count the emails that arrived (since, service, type: code or link, subtype), each summarised without its content                                                                                                                                               |
| GET /v1/addresses/{address}/none                                              | Check that no email arrives: holds up to 30 seconds (timeout), 200 if nothing came, 409 as soon as something does                                                                                                                                              |
| GET /v1/addresses/{address}/checks                                            | Check an email: SPF, DKIM and DMARC, local and plain http links, where its link goes (expect\_host), a stated expiry, a plain-text part, size, image-only emails, given phrases (expect\_text) and its language (expect\_language). Verdicts only              |
| GET /v1/addresses/{address}/messages                                          | Emails with their codes and links; include=text adds the text, include=html the HTML, include=text,html both                                                                                                                                                   |
| GET /v1/addresses/{address}/variants                                          | Unusual versions of the address for testing sign-up forms: ones that reach its inbox, ones that can’t, and invalid ones ([more](https://proxymail.qa/docs/unusual-addresses))                                                                                  |
| GET /v1/addresses/{address}/delivery                                          | Check an address: what reached it, what we refused and why, and a hint                                                                                                                                                                                         |
| GET /v1/addresses/{address}/password                                          | A saved password, for keys allowed to read them                                                                                                                                                                                                                |
| POST /v1/addresses/sign-in                                                    | The saved test account to sign in with and its password, in one call (site, environment, tags), for keys allowed to read passwords ([more](https://proxymail.qa/docs/rest-api#signing-in-again-as-a-test-account))                                             |
| PUT /v1/addresses/{address}/password                                          | Save the password the test account uses now (password, or leave it out to generate one, returned once); any key can save one                                                                                                                                   |
| POST /v1/addresses/checkout                                                   | Check out an account from the pool: one matching the filters (tags, country, min\_age, max\_age, has\_password, search, site, environment) that nobody else holds, for minutes ([more](https://proxymail.qa/docs/rest-api#an-account-pool-for-parallel-tests)) |
| POST /v1/addresses/{address}/extend, POST /v1/addresses/{address}/return      | Keep it longer, or give it back (token; on return, add\_tags and remove\_tags record its state)                                                                                                                                                                |
| POST /v1/runs                                                                 | Start a run (name, source, meta): calls that send its id in the Proxy-Mail-QA-Run header add what they saw ([more](https://proxymail.qa/docs/rest-api#runs-a-report-for-the-pull-request))                                                                     |
| GET /v1/runs, GET /v1/runs/{id}                                               | List runs, or read one with its evidence; format=markdown gives the report                                                                                                                                                                                     |
| POST /v1/runs/{id}/notes, POST /v1/runs/{id}/tests, POST /v1/runs/{id}/finish | Add a line of your own (text, failed), a test result or the totals, or finish it (failed)                                                                                                                                                                      |

## Values for a sign-up form

When a form asks for more than an email, send what the page says about each field and get back what to type or pick in each. The values come from a new address made for the form: it gets a date of birth when the form asks for one (adult unless you pass `age`), a country the form’s drop-down offers (or `country`), and a password that fits the password field’s `min_length`, `max_length` and `pattern`, saved on the address and shown only in this answer. Pass `address` to use one made earlier instead (its saved password isn’t returned).

Terminal window

```bash
curl -s -X POST https://api.proxymail.qa/v1/form-values -H "Authorization: Bearer $PROXYMAIL_QA_API_KEY" \
  -H "Content-Type: application/json" -d '{
    "fields": [
      { "id": "email", "type": "email", "label": "Email" },
      { "id": "user", "name": "username", "label": "Username", "max_length": 15 },
      { "id": "pw", "type": "password", "label": "Password", "min_length": 12 },
      { "id": "dob", "type": "text", "label": "Date of birth", "placeholder": "DD/MM/YYYY" },
      { "id": "country", "type": "select", "label": "Country", "options": [{ "value": "de", "label": "Germany" }, { "value": "uk", "label": "United Kingdom" }] },
      { "id": "terms", "type": "checkbox", "label": "I agree to the terms" }
    ]
  }'
# { "address": "k7f2x9ab12cd@mailclub.dev", "created": true, "password": "…", "persona": {…},
#   "values": [ { "id": "email", "kind": "email", "value": "k7f2x9ab12cd@mailclub.dev" },
#               { "id": "dob", "kind": "date_of_birth", "value": "04/05/1990" },
#               { "id": "country", "kind": "country", "value": "uk" },
#               { "id": "terms", "kind": "other", "value": null, "note": "Not a sign-up detail Proxy Mail QA fills: fill it yourself, or leave it." }, … ] }
```

Each field can have an `id` (yours, returned with its value), `type` (or `select`), `name`, `label`, `placeholder`, `autocomplete`, `group` (what the group around it says, such as “Date of birth” above day, month and year drop-downs), `pattern`, `min_length`, `max_length` and `options` (`value` and `label`). Day, month and year fields each get their part, a date picker gets `YYYY-MM-DD`, and a typed date gets the format its label or placeholder spells out (US order with a `note` when nothing says). Proxy Mail QA never sees or fills in the page: you do, then wait for the code or link as usual.

## Variants of an address

Wherever the API takes an address, a variant works too: capitals, or a plus tag such as `k7f2x9ab12cd+o'brien@mailclub.dev` (URL-encoded in the path). It acts on the plain address, and a wait or message list for a `+tag` variant only finds mail sent to that variant. See [Unusual email addresses](https://proxymail.qa/docs/unusual-addresses).

## Dates of birth

Add `"age": "under13"` (or `adult`, `over21`, `under18`, `under15`, `any`, `turns18tomorrow`, `turned18today`) to give the persona a date of birth in that range, or send your own as `"date_of_birth": "2012-05-01"`. It comes back as `persona.date_of_birth`, YYYY-MM-DD, and only when the address has one.

## Finding a test account again

`GET /v1/addresses` takes filters, so a test or agent can come back to an account it made earlier instead of creating another. Every filter given must match:

- `tag`: repeat it, or separate tags with commas, to need all of them
- `country`: the persona’s country, such as `GB`
- `min_age` and `max_age`: age today in whole years, from the persona’s date of birth (`max_age=17` finds under-18s); addresses without a date of birth don’t match
- `has_password`: `true` or `false`, whether a password is saved on it
- `search`: part of the address, label, notes, name or username
- `site` and `environment`: where it’s used, as a host or page URL (`site=staging.example.com`) and a name (`environment=staging`). Set them when creating an address; the Chrome extension records them too, and the app shows them on each account

Terminal window

```bash
curl "https://api.proxymail.qa/v1/addresses?tag=age-gate&country=GB&max_age=17&has_password=true" \
  -H "Authorization: Bearer $PROXYMAIL_QA_API_KEY"
```

## Signing in again as a test account

`POST /v1/addresses/sign-in` picks the saved test account to sign in with and returns it with its password, as the Chrome extension’s sign-in list does: one with a saved password and every tag given, that no test has checked out, the one used on this `site` first, then the one that got mail most recently. With a `site`, an account not used anywhere yet is marked as used there. It needs a key allowed to read saved passwords, every reveal is logged, and it answers 404 when no account fits.

Terminal window

```bash
curl -s -X POST https://api.proxymail.qa/v1/addresses/sign-in -H "Authorization: Bearer $PROXYMAIL_QA_API_KEY" \
  -H "Content-Type: application/json" -d '{"site": "staging.example.com", "tags": ["verified"]}'
# { "address": "…", "password": "…", "persona": {…}, "site": "staging.example.com", "used_on_site": true, … }
```

## Checking the email itself

To test an email template (the wording, the button, the footer), ask the messages endpoint for its content: `GET /v1/addresses/{address}/messages?include=html` adds each email’s HTML, and `include=text` its plain text. The HTML is the copy cleaned when the email arrived: scripts, frames and media are removed, form controls unwrapped, and links other than web, `mailto:` and `tel:` dropped (app links like `myapp://` stay in the codes and links), up to 200 KB. The content is written by the sender, so treat it as untrusted. Agents connected through MCP never get email content, only the code or link.

## Countries

Add `"country": "GB"` (any ISO 3166-1 alpha-2 code, in either case) for sign-up forms that ask where someone lives, or to test location-based content. It comes back as `persona.country`, upper case, and only when the address has one. Change or clear it with `PATCH` and `"country": null`. Agents connected through MCP pass `country` to `create_address` and `update_address` too.

## How waiting works

A wait returns the newest matching code or link received in the last 15 minutes, or holds for up to 30 seconds (`timeout`, 0 to 30) until one arrives, then answers 404\. Pass `since` to ignore anything older, for example the time your test submitted the form. Filter by sender with `service=spotify`.

A code or link counts as used once a wait returns it, or someone opens or copies it in the app. The app shows it as used, and `unused=true` on a wait skips it: for a test that asks for a second code without changing `since`.

## Checking that an email didn’t arrive, or arrived once

Some tests pass when nothing happens: an unsubscribed user gets no newsletter, a second reset request within a minute is refused, a sign-up sends one verification email, not two.

- `GET /v1/addresses/{address}/none?since=…&timeout=20` holds like a wait. If no matching email arrives, it answers 200 with `"arrived": false` and `until`, the time it watched up to. As soon as one arrives it answers **409** with `"error": "email_arrived"`, the `count`, and the first email’s time, service and kind, so `curl --fail` fails the step. Pass `since` as the time of the action, so an email that came before the call still counts; without it, it watches from the request. For longer than 30 seconds, call again with the same `since`.
- `GET /v1/addresses/{address}/count?since=…` answers at once with `count` and the first 50 emails, oldest first. For “exactly one”, count after the wait for the first email has returned and a duplicate has had time to arrive.

Both filter like a wait: `service`, and `type=code` (codes other than coupons) or `type=link` (verify, magic sign-in, invite and reset links), narrowed with `subtype`, such as `type=link&subtype=reset`. A `+tag` variant counts only its own mail. Neither returns what the emails say: each is summarised by `received_at`, `service` and the `type` and `subtype` of its main code or link. The [Node.js client](https://proxymail.qa/docs/node) has `countEmails` and `expectNoEmail`, and the [Python helper](https://proxymail.qa/docs/python) `count_emails` and `expect_no_email`.

## Checking an email’s sending and build

`GET /v1/addresses/{address}/checks` runs checks on one email: the newest that matches the same filters as a count (`since`, `service`, `type`, `subtype`), or the one named by `email_id`. It doesn’t wait, so wait for the email first. Each check passes, fails, warns or is skipped, with a one-line reason, and `passed` is true when none failed:

| Check            | Fails or warns when                                                                                                                                                                                                                                                                                                     |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| spf, dkim, dmarc | The sending server isn’t allowed to send for the domain, the email isn’t signed, or it fails the From domain’s DMARC policy: inboxes may file it as spam or reject it. These are the receiving server’s own verdicts                                                                                                    |
| local\_links     | A link goes to localhost or a private address, directly or after a redirect such as Supabase’s redirect\_to. Links to a host marked as a dev environment (in Project settings, or POST /v1/project/dev-hosts) pass                                                                                                      |
| plain\_http      | A link on a public site isn’t https                                                                                                                                                                                                                                                                                     |
| expected\_host   | With expect\_host=staging.example.com: the sign-in, verify, reset or invite link ends up somewhere else, such as production. Links that open an app (myapp://…) have no site and aren’t compared. Skipped without it                                                                                                    |
| expiry           | The email doesn’t say when its code or link stops working (a warning)                                                                                                                                                                                                                                                   |
| plain\_text      | There’s no plain-text version (a warning)                                                                                                                                                                                                                                                                               |
| size             | The HTML is over 102 KB, where Gmail cuts it off (a warning)                                                                                                                                                                                                                                                            |
| images\_only     | It’s almost all images, so with images off it says almost nothing                                                                                                                                                                                                                                                       |
| layout           | It may not fit a phone: parts fixed wider than 640 px with no mobile styles (@media), images without alt text (blank with images off), or text under 11 px. A warning, from the HTML alone, without rendering it                                                                                                        |
| expected\_text   | With expect\_text (repeat it for up to 10 phrases): the subject or body is missing one, such as the user’s first name or the product name. Case, curly quotes and spacing don’t matter, and the reason names only the missing phrases. Skipped without it                                                               |
| language         | With expect\_language=de (or pt-BR): it’s in another language. It warns when there’s too little text to tell. English, German, French, Spanish, Italian, Dutch, Portuguese, Swedish, Polish, Turkish, Finnish, Russian, Ukrainian, Japanese, Chinese, Korean, Arabic, Hebrew, Greek, Thai and Hindi. Skipped without it |

Terminal window

```bash
curl "https://api.proxymail.qa/v1/addresses/k7f2x9ab12cd@mailclub.dev/checks?type=link&expect_host=staging.example.com" \
  -H "Authorization: Bearer $PROXYMAIL_QA_API_KEY"
```

```json
{
  "address": "k7f2x9ab12cd@mailclub.dev",
  "email": { "id": "…", "received_at": "2026-10-17T10:00:01Z", "service": "Supabase", "service_domain": "supabase.io", "type": "link", "subtype": "verify" },
  "passed": false,
  "failed": ["local_links"],
  "warnings": ["expiry"],
  "checks": [
    { "check": "spf", "status": "pass", "detail": "The sending server is allowed to send for supabase.io." },
    { "check": "local_links", "status": "fail", "detail": "A link goes to localhost:3000, which only opens on the computer running that server." }
  ]
}
```

For localised emails, check each locale’s copy: `?type=code&expect_language=fr&expect_text=Bonjour%20Maya`.

The answer never holds what the email says or its links: reasons name at most a domain, a link’s host or your own phrases, so an agent can act on it without reading untrusted email text. The [Node.js client](https://proxymail.qa/docs/node) has `checkEmail`, and the [Python helper](https://proxymail.qa/docs/python) `check_email`.

## An account pool for parallel tests

Keep test accounts in known states and let each test borrow one: tag them with their state (`verified`, `premium`, `trial`), then check one out. You get an account that matches every filter and that nobody else has checked out, the least recently used first, held for `minutes` (10 unless you ask, at most 60). Parallel tests never get the same one.

Terminal window

```bash
curl -s -X POST https://api.proxymail.qa/v1/addresses/checkout -H "Authorization: Bearer $PROXYMAIL_QA_API_KEY" \
  -H "Content-Type: application/json" -d '{"tags":["verified","premium"],"country":"DE","minutes":5}'
# { "address": "…", "persona": {…}, "tags": ["premium", "verified"], "checkout": { "token": "…", "until": "…" } }
```

Sign in as it (with its saved password, for keys that can read them), run the test, then return it with the token. Returning can record the state the test left it in, so the pool stays accurate:

Terminal window

```bash
curl -s -X POST "https://api.proxymail.qa/v1/addresses/$ADDRESS/return" -H "Authorization: Bearer $PROXYMAIL_QA_API_KEY" \
  -H "Content-Type: application/json" -d "{\"token\":\"$TOKEN\",\"add_tags\":[\"cancelled\"],\"remove_tags\":[\"premium\"]}"
```

The [Node.js client](https://proxymail.qa/docs/node#accounts-in-a-known-state) and the [Python helper](https://proxymail.qa/docs/python#an-account-pool-for-parallel-tests) can also make, check and reset accounts with hooks from your tests, so a test gets an account in the state it needs.

A checkout that isn’t returned frees itself when its time runs out, so a crashed test never holds an account for long; `extend` gives it more time. When every match is checked out, the answer is 409 and says when the first comes free; when none match, 404\. The app shows who has an account and until when, and anyone on the project can free it.

## Runs: a report for the pull request

A run groups a CI job or an agent’s session, and keeps the evidence of what its calls saw. Start one, then send its `id` in the `Proxy-Mail-QA-Run` header of each call:

Terminal window

```bash
RUN=$(curl -s -X POST https://api.proxymail.qa/v1/runs -H "Authorization: Bearer $PROXYMAIL_QA_API_KEY" \
  -H "Content-Type: application/json" -d '{"name":"Sign-up tests","source":"ci"}' | jq -r .id)
curl -s "https://api.proxymail.qa/v1/addresses/$ADDRESS/code?since=$SUBMITTED" \
  -H "Authorization: Bearer $PROXYMAIL_QA_API_KEY" -H "Proxy-Mail-QA-Run: $RUN"
curl -s -X POST "https://api.proxymail.qa/v1/runs/$RUN/finish" -H "Authorization: Bearer $PROXYMAIL_QA_API_KEY"
curl -s "https://api.proxymail.qa/v1/runs/$RUN?format=markdown" -H "Authorization: Bearer $PROXYMAIL_QA_API_KEY"
```

`GET /v1/runs/{id}` also compares the run with the last 10 finished runs of the same name (`comparison` in JSON, a list near the top of the Markdown report): much slower emails, a different number of emails, new and intermittent test failures, newly failing checks, and a new sender or link site.

Each call adds what it saw: an address made, an email that arrived (the code, or the link’s site and path with anything that looks like a token hidden, and how many seconds after `since`), a count, email checks, and an email that never came or came when it shouldn’t have. Finishing passes the run unless one of those failed, a note failed it, or you send `{"failed": true}`. The report is Markdown, for a pull request comment or a job summary, and the run is in the app for everyone in the project, for 90 days.

To say which test a call belongs to, send its name, percent-encoded, in `Proxy-Mail-QA-Test` (such as `sign-up%20%E2%80%BA%20sends%20one%20code`): the report shows it beside what the call saw. A test runner’s reporter sends results to `POST /v1/runs/{id}/tests`: each failed or flaky test as `{"name", "status": "failed", "duration_ms", "error"}` (a failed test fails the run), then `{"totals": {"passed", "failed", "skipped", "flaky"}}` for the top of the report.

Evidence never fails a call: if a call can’t be added (the run has finished, or isn’t in this key’s project), it still works and the `Proxy-Mail-QA-Run-Warning` header says why. A client that asks again when a wait comes back empty sends `Proxy-Mail-QA-Run-Retry: true` on all but the last try, so only the last “nothing arrived” counts. In GitHub Actions, the [Proxy Mail QA action](https://proxymail.qa/docs/ci#a-report-on-the-pull-request) does all of this.

## When nothing arrives

The 404 also says why, as far as we can tell: `hint` names anything we refused for the address (too large, or over a limit) or, when nothing has ever reached it, the likeliest cause; `refused` lists what we turned away since `since`; and `help` links to [Test email not arriving?](https://proxymail.qa/troubleshooting). The MCP tools answer `found: false` with the same `message`, `refused` and `help`. To check an address at any time without waiting, call `GET /v1/addresses/{address}/delivery`: it returns the emails received so far, every refusal still on record and a `hint`. Your app has to be able to email any address: an email provider in sandbox or test mode only sends to addresses you’ve verified.

## Errors

Errors come back as `{"error": "not_found", "message": "…"}` with the matching status: 400, 401, 402 (an organisation without a plan), 403, 404, 409 (an email arrived during a `none` check) or 429\. Request limits are in [Limits and safety](https://proxymail.qa/docs/limits). A 500, or requests that time out for everyone, may be an incident on our side: see [our status page](https://proxymailqa.statuspage.io).

[Previous MCP server](https://proxymail.qa/docs/mcp.html) [Next Node.js client](https://proxymail.qa/docs/node.html)
