POST /api/v1/check answers for one address. POST /api/v1/lists takes up to 100,000, checks them in the background, and calls your webhook when they are done. One credit an address.Before you start
- Address. Every request goes to
https://bouncetest.com/api/v1/…, over HTTPS only. - Format. You send JSON (
Content-Type: application/json) and get JSON back, except for the CSV download. - Key. Log in, open your account page, and under “API” press Make a key. It is shown once: copy it then. We keep only a fingerprint of it, so nobody, including us, can read it back.
Authentication
Send the key in the Authorization header of every request:
Authorization: Bearer bt_your_key_here
For tools that cannot set that header, X-API-Key: bt_your_key_here works too. A request without a key, or with one that is wrong, replaced or deleted, gets 401.
The key stands for your account: whoever has it can spend your credits and read your lists. Keep it on your server. Do not put it in a web page, a mobile app or a public repository. If it may have leaked, make a new one on your account page; the old one stops working at once.
curl https://bouncetest.com/api/v1/me \
-H "Authorization: Bearer $BOUNCETEST_KEY"
{ "user": { "email": "you@company.com", "role": "user", "credits": 4980, "checks": 20 } }
Check one address
POST/api/v1/check
Asks the address's mail server and answers in the same request. Most answers come within a few seconds; a slow mail server can take up to two minutes, so give your HTTP client a timeout of at least 150 seconds. Costs one credit.
curl https://bouncetest.com/api/v1/check \
-H "Authorization: Bearer $BOUNCETEST_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "anna@example.com"}'
{
"email": "anna@example.com",
"status": "valid",
"reason": "accepted",
"provider": "google.com"
}
| Field | What it is |
|---|---|
email | The address that was checked. |
status | valid, invalid, risky, disposable or unknown. See statuses and reasons. |
reason | Why, as a short code. |
provider | Who runs the mail server, when one was asked. |
did_you_mean | The domain the writer probably meant, when the one given looks like a typing slip (gamil.com gives gmail.com). Absent otherwise. |
The same in Node and in Python:
const response = await fetch("https://bouncetest.com/api/v1/check", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.BOUNCETEST_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email: "anna@example.com" }),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
console.log(result.status); // "valid"
import os, requests
response = requests.post(
"https://bouncetest.com/api/v1/check",
headers={"Authorization": f"Bearer {os.environ['BOUNCETEST_KEY']}"},
json={"email": "anna@example.com"},
timeout=90,
)
response.raise_for_status()
print(response.json()["status"]) # "valid"
One account can have five single checks waiting at once. For more than a handful of addresses, send a list: it is quicker, and nothing has to wait for it.
Check a list
POST/api/v1/lists
Takes up to 100,000 addresses and checks them in the background. The answer comes at once, with the list's id; the results follow as they are found. One credit an address, taken when the list is accepted; an address that appears twice is checked, and counted, once.
curl https://bouncetest.com/api/v1/lists \
-H "Authorization: Bearer $BOUNCETEST_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: import-2026-10-13" \
-d '{
"name": "October customers",
"emails": ["anna@example.com", "omar@example.org", "li.wei@example.net"],
"webhook_url": "https://your-server.example.com/hooks/bouncetest"
}'
| Field | What it is |
|---|---|
emails | The addresses, as a list of strings. Required, unless you send text. |
text | The same as one string, one address to a line. Use this or emails. |
name | A name for the list, shown on the site and used for the download. Optional. |
webhook_url | Where to send word when the list is finished. Optional. See webhooks. |
The answer is 201 with the list:
{
"list": {
"id": "9f2c1a7d5b3e8c40",
"name": "October customers",
"state": "queued",
"total": 3,
"checked": 0,
"counts": { "valid": 0, "invalid": 0, "risky": 0, "disposable": 0, "unknown": 0 },
"created_at": "2026-10-13T09:15:02.118Z",
"webhook": { "url": "https://your-server.example.com/hooks/bouncetest", "state": "pending", "attempts": 0 }
}
}
Sending the same request twice. If your program gets no answer (a timeout, a dropped connection), it cannot know whether the list was made. Send an Idempotency-Key header with a name of your own for the request, up to 100 characters without spaces. A second request with the same key gets the list the first one made, with 200 instead of 201, and nothing is charged again.
Your lists of more than 1,000 addresses are checked one after another: a second one waits, as queued, until the first is done. A list of up to 1,000 addresses starts at once.
Follow a list
GET/api/v1/lists/{id}
Says how far along the list is. state goes from queued to checking to done; checked counts the addresses with a final result, and counts says how they came out. finished_at appears when the list is done. A list stopped on the site ends as stopped instead: checked says how far it got, its results so far stay, and the credits for the addresses not checked are given back.
curl https://bouncetest.com/api/v1/lists/9f2c1a7d5b3e8c40 \
-H "Authorization: Bearer $BOUNCETEST_KEY"
{
"list": {
"id": "9f2c1a7d5b3e8c40",
"name": "October customers",
"state": "done",
"total": 3,
"checked": 3,
"counts": { "valid": 2, "invalid": 1, "risky": 0, "disposable": 0, "unknown": 0 },
"created_at": "2026-10-13T09:15:02.118Z",
"finished_at": "2026-10-13T09:15:41.902Z",
"webhook": { "url": "https://your-server.example.com/hooks/bouncetest", "state": "delivered", "attempts": 1, "last_status": 200 }
}
}
A large list can take hours, because each mail provider is asked at a pace it accepts. If you poll, once a minute is plenty; with a webhook you do not need to poll at all.
GET/api/v1/lists
All your lists, newest first, in the same form. Lists you uploaded on the site are among them.
Read the results
GET/api/v1/lists/{id}/results
The addresses checked so far, in the order you sent them. You can read results while the list is still being checked.
| Query | What it does |
|---|---|
status | Only results of this kind: valid, invalid, risky, disposable or unknown. |
limit | How many to return: 100 unless you say otherwise, 1,000 at most. |
offset | How many to skip, for the next page. |
curl "https://bouncetest.com/api/v1/lists/9f2c1a7d5b3e8c40/results?status=invalid&limit=1000" \
-H "Authorization: Bearer $BOUNCETEST_KEY"
{
"list": { "id": "9f2c1a7d5b3e8c40", "state": "done", "total": 3, "checked": 3, "…": "…" },
"results": [
{ "email": "omar@example.org", "status": "invalid", "reason": "mailbox_not_found", "provider": "example-host.net" }
],
"matching": 1,
"offset": 0,
"limit": 1000
}
matching is how many results there are of the kind you asked for. Ask again with offset raised by limit until you have that many.
GET/api/v1/lists/{id}/download
The whole list as a CSV file with the columns email,status,reason,provider,did_you_mean. Add ?only=valid for the addresses that are safe to send to, ?only=valid_risky for those and the risky ones, or ?only=invalid for the ones to remove.
Delete a list
DELETE/api/v1/lists/{id}
Removes the list and its results; the answer is 204 with no body. A list that is still being checked is stopped, and the credits for the addresses it never got to are given back.
Webhooks
Give a webhook_url when you send a list, and when the list is finished (or stopped on the site, with state stopped) we send one POST to that address. It says which list is done and how it came out. It never contains the addresses themselves: your program fetches those with its key.
POST /hooks/bouncetest HTTP/1.1
Host: your-server.example.com
Content-Type: application/json
User-Agent: BounceTest-Webhook/1.0 (+https://bouncetest.com/docs)
X-BounceTest-Event: list.finished
X-BounceTest-Delivery: 5f0c8a52-6d0e-4b0c-9a55-2f1f0f6f6b1e
X-BounceTest-Signature: t=1791885600,v1=5b1f…e3a9
{
"event": "list.finished",
"sent_at": "2026-10-13T09:15:42.004Z",
"list": {
"id": "9f2c1a7d5b3e8c40",
"name": "October customers",
"state": "done",
"total": 3,
"checked": 3,
"counts": { "valid": 2, "invalid": 1, "risky": 0, "disposable": 0, "unknown": 0 },
"created_at": "2026-10-13T09:15:02.118Z",
"finished_at": "2026-10-13T09:15:41.902Z"
}
}
What your server has to do
- Answer with a 2xx status within 10 seconds. Do the real work afterward. Anything else counts as not delivered.
- Expect a repeat. A webhook that was not delivered is sent again after 30 seconds, after 5 minutes and after 30 minutes, and then no more. If your answer got lost on the way, you receive the same news twice: act on a list's
idonce. - Do not depend on it alone. How the delivery went is in the list's
webhookfield (pending,deliveredorfailed), andGET /api/v1/lists/{id}always tells the truth.
What the address has to be
An https:// address, on the usual port, with a domain name that leads to a server on the public internet, and a certificate that is valid. An address with a login in it, another port, an IP number in place of a name, or a name that leads into a private network is refused when you send the list. Redirects are not followed.
Checking the signature
Anyone can send a POST to your address. The signature is how you know this one came from us. Your signing secret is on your account page, under “Webhook signing secret”; it begins with whsec_.
The header has two parts: t, the time the webhook was made, in seconds, and v1, an HMAC-SHA256, made with your secret, of the text t + "." + body, where body is the request body exactly as received, before any JSON parsing. To check it:
- Take
tandv1from theX-BounceTest-Signatureheader. - Compute the HMAC-SHA256 of
t + "." + bodywith your secret, as hex. - Compare it with
v1using a constant-time comparison. - Refuse it if
tis more than five minutes away from now, so that an old webhook cannot be sent to you again.
In Node (Express):
import crypto from "node:crypto";
import express from "express";
const app = express();
const SECRET = process.env.BOUNCETEST_WEBHOOK_SECRET;
// The raw body is needed: the signature is over the bytes as they arrived.
app.post("/hooks/bouncetest", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-BounceTest-Signature") ?? "";
const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header);
if (!match) return res.sendStatus(400);
const [, t, v1] = match;
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${req.body}`).digest("hex");
const genuine = crypto.timingSafeEqual(Buffer.from(v1, "hex"), Buffer.from(expected, "hex"));
const recent = Math.abs(Date.now() / 1000 - Number(t)) < 300;
if (!genuine || !recent) return res.sendStatus(400);
const { event, list } = JSON.parse(req.body);
res.sendStatus(200); // answer first, work afterward
if (event === "list.finished") fetchResults(list.id);
});
In Python (Flask):
import hashlib, hmac, os, re, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["BOUNCETEST_WEBHOOK_SECRET"].encode()
@app.post("/hooks/bouncetest")
def bouncetest_hook():
match = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", request.headers.get("X-BounceTest-Signature", ""))
if not match:
abort(400)
t, v1 = match.groups()
body = request.get_data() # the raw bytes, not the parsed JSON
expected = hmac.new(SECRET, t.encode() + b"." + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(v1, expected) or abs(time.time() - int(t)) > 300:
abort(400)
payload = request.get_json()
if payload["event"] == "list.finished":
enqueue_fetch(payload["list"]["id"])
return "", 200
In PHP:
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_BOUNCETEST_SIGNATURE'] ?? '';
if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $m)) { http_response_code(400); exit; }
$expected = hash_hmac('sha256', $m[1] . '.' . $body, getenv('BOUNCETEST_WEBHOOK_SECRET'));
if (!hash_equals($expected, $m[2]) || abs(time() - (int) $m[1]) > 300) { http_response_code(400); exit; }
http_response_code(200);
$payload = json_decode($body, true);
If the secret may have leaked, make a new one on your account page. Webhooks are signed with the new one from that moment.
Statuses and reasons
| Status | What was found | What to do |
|---|---|---|
valid | The mailbox exists. | Send. |
invalid | No such mailbox, or the domain cannot receive mail. | Remove. |
risky | Mail may bounce: the server accepts every address, or the mailbox is full. | Your call. |
disposable | A temporary address from a throwaway-mail service. | Remove. |
unknown | The mail server gave no answer this time. | Check again later. |
The reason says more. New reasons may be added, so treat one you do not know like the plain status.
| Reason | Status | Meaning |
|---|---|---|
accepted | valid | The mail server accepted the address, and refuses ones that do not exist. |
accepted_low_confidence | valid | Accepted, at a provider whose “yes” is right most of the time but not always. |
confirmed_by_gravatar | valid | The server would not say, but a public profile is registered for the address. |
mailbox_not_found | invalid | The mail server says there is no such mailbox. |
rejected_unrecognised | invalid | The mail server refused the address, in words we could not place. |
domain_not_served | invalid | The domain's mail server says it does not handle the domain, so mail to it bounces. This often lasts only while a company moves its mail, so it is checked afresh next time. |
syntax | invalid | Not written like an email address. |
no_mx | invalid | The domain has no mail server. |
dns_broken | invalid | The domain's DNS does not work, so it cannot receive mail. |
accepts_all | risky | The server accepts every address, real or not. |
mailbox_full | risky | The mailbox exists and is full; mail bounces until its owner makes room. |
disposable_domain | disposable | The domain belongs to a temporary-address service. |
smtp_temporary | unknown | The mail server asked us to come back later. |
smtp_throttled | unknown | The mail server asked us to slow down. |
smtp_blocked | unknown | The mail server refused our check. |
smtp_unreachable | unknown | The mail server did not answer. |
catch_all_unconfirmed | unknown | Accepted, but we could not find out whether the server accepts everything. |
dns_error | unknown | We could not look the domain up. |
unsupported_address | unknown | The name before the @ uses characters we cannot check. |
earlier_answer | valid, invalid or risky | No answer this time; the status is what the mail server said when we last checked. |
Credits
- One credit checks one address, by a single check or in a list.
- An address that appears more than once in a list is checked, and counted, once.
- A list is paid for when it is accepted. If you have too few credits, the list is refused with
402and nothing is taken. - Delete a list before it is finished and the credits for the addresses not yet checked come back.
GET /api/v1/mesays how many credits you have left.
Limits
| What | Limit |
|---|---|
| Requests | 300 a minute for one account |
| Single checks waiting at once | 5 for one account |
| Addresses in one list | 100,000 |
| Size of one request | 12 MB |
| Results in one page | 1,000 |
A request over a limit gets 429 with a Retry-After header that says, in seconds, how long to wait. Nothing it asked for is done or charged.
Errors
An error is a JSON body with one sentence in it, and an HTTP status that says what kind it is:
{ "error": "Send the address to check as \"email\"." }
| Status | Meaning |
|---|---|
400 | The request cannot be read as it is: a field is missing or not what it should be. |
401 | No key, or a key that is wrong, replaced or deleted. |
402 | Not enough credits. |
403 | The key may not do this. |
404 | No such address, or no such list among yours. |
413 | Too large: more than 100,000 addresses, or more than 12 MB. |
429 | Too many requests. Wait as long as Retry-After says. |
500 | Something went wrong on our side. Try again; a list sent with an Idempotency-Key is safe to send again. |
Ten requests with a wrong key from one network within fifteen minutes, and that network is refused until the fifteen minutes are over, right key or not.
Keeping it safe
- The key lives on your server. Read it from an environment variable or a secrets store, never from code that reaches a browser or a phone.
- We cannot show a key again. Only its fingerprint is kept. If you lose it, make a new one.
- Replace a key that may have leaked. The old one stops working the moment the new one is made.
- Check every webhook's signature before you act on it, and refuse old ones.
- Your lists are yours. A key reaches only its own account's lists; anyone else's is answered as if it did not exist.