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.
Quick start
Section titled “Quick start”Use an API key from the project you’re testing.
# Create an address with a persona and a passwordcurl -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 codecurl "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"}Endpoints
Section titled “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) |
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) |
Values for a sign-up form
Section titled “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).
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
Section titled “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.
Dates of birth
Section titled “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
Section titled “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 themcountry: the persona’s country, such asGBmin_ageandmax_age: age today in whole years, from the persona’s date of birth (max_age=17finds under-18s); addresses without a date of birth don’t matchhas_password:trueorfalse, whether a password is saved on itsearch: part of the address, label, notes, name or usernamesiteandenvironment: 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
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
Section titled “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.
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
Section titled “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
Section titled “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
Section titled “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
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=20holds like a wait. If no matching email arrives, it answers 200 with"arrived": falseanduntil, the time it watched up to. As soon as one arrives it answers 409 with"error": "email_arrived", thecount, and the first email’s time, service and kind, socurl --failfails the step. Passsinceas 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 samesince.GET /v1/addresses/{address}/count?since=…answers at once withcountand 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.
Checking an email’s sending and build
Section titled “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 |
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.
An account pool for parallel tests
Section titled “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.
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:
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.
Runs: a report for the pull request
Section titled “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:
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.
When nothing arrives
Section titled “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?. 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
Section titled “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. A 500, or requests that time out for everyone, may be an incident on our side: see our status page.