Skip to content

REST API

Base URL https://api.proxymail.qa/v1, with Authorization: Bearer qa_…. Every endpoint is in the REST API reference, generated from the OpenAPI description.

Using JavaScript or TypeScript? The proxymail-qa package on npm wraps these calls, with types.

Use an API key from the project you’re testing.

Terminal window
# 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:

{
"type": "code",
"subtype": "otp",
"value": "986512",
"service": "Spotify",
"received_at": "2026-10-12T10:00:03Z",
"expires_at": "2026-10-12T10:10:03Z"
}
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)
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)
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)
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)
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)
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)

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

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.

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.

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
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"

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
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, … }

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.

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.

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

Section titled “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 has countEmails and expectNoEmail, and the Python helper count_emails and expect_no_email.

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
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"
{
"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 has checkEmail, and the Python helper check_email.

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
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
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 and the Python helper 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.

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
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 does all of this.

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?. 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 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. A 500, or requests that time out for everyone, may be an incident on our side: see our status page.