TL;DR
- Errors are JSON:
{"error": "message"}with a status of 400, 401, 402, 404 or 429. Retry only 429 (and network failures). Fix the rest. - Credits are charged when you submit, one per unique address. Duplicates are free and unknown results are refunded when the list finishes.
- A
202 pendingfrom/verifyis not an error. The check is running and paid for, so read the result from the list. Never post the address again.
Status codes
Every endpoint under https://mailexchange.com/api/v1 answers errors in the same shape. The message strings below are what the API returns today. Match on the status code, not the text.
| Status | Meaning | Example message | What to do |
|---|---|---|---|
| 200 / 201 | OK / list created | Carry on. | |
| 202 | Single check still running | {"state": "pending", "list_id": 4821} | Poll the list. Do not resend. |
| 400 | Bad request | email required, emails required, max 100000 addresses per list, callback_url must be https, invalid JSON | Fix the request. Do not retry unchanged. |
| 401 | Key missing or wrong | missing or invalid API key | Check the Authorization header and the key. |
| 402 | Not enough credits | insufficient credits: 500 needed | Stop and top up. Nothing was created or charged. |
| 404 | Unknown list or route | list not found, not found | Check the ID. Lists belong to the key's account. |
| 429 | Rate limit | rate limit exceeded | Back off and retry. |
Treat a 5xx or a network error as temporary and retry a few times with backoff, but see the note on timeouts below before blindly repeating a submit. Language examples of all this are in Python, Node.js, PHP, Ruby, Go, Java and C#.
How credits work
- Charged at submission.
POST /liststakes one credit per unique address immediately.POST /verifydoes the same for its one address, because a single check is a one-address list. - Duplicates are free. Addresses are trimmed and lower-cased, and repeats inside the list are dropped and not charged. The summary shows them as
duplicates_removed. This applies within one list. Submitting the same address in two separate calls charges twice. - Unknowns are refunded. When a list finishes, one credit comes back for every result with the state
unknown, so you only pay for answers. For the unknown reasons see email verification statuses. - Check your balance.
GET /v1/accountreturns your name,credits,credits_usedand the number oflists.
curl -H "Authorization: Bearer $MAILEXCHANGE_API_KEY" https://mailexchange.com/api/v1/account
Credits never expire, and the price per credit is on the pricing page. A 402 on a big list means the whole list was refused, not partly processed, so you can top up and submit again.
Rate limits
Limits are per API key over a rolling 60 second window. POST /verify counts one per request. POST /lists counts one per address in the list, with a much larger allowance, so a bulk submit is the right tool for volume. If you exceed either you get 429. A rate-limited call is rejected before any work happens, so it is always safe to repeat it.
The practical rules: reuse connections, use lists for more than a few addresses, poll every 5 to 10 seconds rather than in a tight loop, and add a retry with exponential backoff (1, 2, 4, 8 seconds is plenty). If you regularly need a higher limit, talk to us.
Handling 202 pending idempotently
Most single checks finish within seconds. If the mail server is slow, /verify waits about 20 seconds and then returns 202. That response means the check exists, has a list_id and was already charged. The safe pattern is:
- Save the
list_idfrom the 202 response next to your record for that address. - Poll
GET /v1/lists/{list_id}/results?limit=1every few seconds. While the row'sstateispending, keep waiting. - When the state changes, use that result. If you stop waiting, look again later with the same
list_idinstead of calling/verifyagain.
# Python, using the call() helper from the Python guide
r = call("POST", "/verify", json={"email": email})
result = r.json()
if r.status_code == 202:
store_pending(email, result["list_id"]) # remember it, never re-post
else:
store_result(email, result)
# later, from a worker:
rows = call("GET", f"/lists/{list_id}/results", params={"limit": 1}).json()["results"]
if rows and rows[0]["state"] != "pending":
store_result(email, rows[0])
/verify), and if you still time out, compare GET /account before and after to see whether credits were taken, rather than resubmitting blindly.A simple error policy
- 429: retry with backoff, up to about 4 times, then give up and queue the work.
- 402: alert a human, pause the job, resume after a top-up.
- 401: alert and stop. Retrying never fixes a bad key.
- 400, 404: a bug in the request or a wrong ID. Log the message and the payload.
- 5xx or network error: retry a few times, as above.
- Unknown or pending result: retry the address later. Unknowns are refunded, and resubmitting the address is a new charge.
Questions
Do I pay twice if I retry a /verify that returned 202?
Yes, if you post the address again, because that starts a new check. Read the result of the first one from /lists/{list_id}/results instead.
Am I charged for unknown results?
No. Unknown results are refunded automatically when the list finishes. Duplicates inside a list are never charged.
What should I do on a 429?
Wait and retry with exponential backoff. A rate-limited request does no work and costs no credits.