BounceTest
  1. BounceTest

BounceTest API

Everything the site does, a program can do: check one address, send a list, follow it, and be told when it is finished.

Reference · updated

In shortMake a key on your account page and send it with every request. 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

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"
}
FieldWhat it is
emailThe address that was checked.
statusvalid, invalid, risky, disposable or unknown. See statuses and reasons.
reasonWhy, as a short code.
providerWho runs the mail server, when one was asked.
did_you_meanThe 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"
  }'
FieldWhat it is
emailsThe addresses, as a list of strings. Required, unless you send text.
textThe same as one string, one address to a line. Use this or emails.
nameA name for the list, shown on the site and used for the download. Optional.
webhook_urlWhere 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.

QueryWhat it does
statusOnly results of this kind: valid, invalid, risky, disposable or unknown.
limitHow many to return: 100 unless you say otherwise, 1,000 at most.
offsetHow 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

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:

  1. Take t and v1 from the X-BounceTest-Signature header.
  2. Compute the HMAC-SHA256 of t + "." + body with your secret, as hex.
  3. Compare it with v1 using a constant-time comparison.
  4. Refuse it if t is 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

StatusWhat was foundWhat to do
validThe mailbox exists.Send.
invalidNo such mailbox, or the domain cannot receive mail.Remove.
riskyMail may bounce: the server accepts every address, or the mailbox is full.Your call.
disposableA temporary address from a throwaway-mail service.Remove.
unknownThe 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.

ReasonStatusMeaning
acceptedvalidThe mail server accepted the address, and refuses ones that do not exist.
accepted_low_confidencevalidAccepted, at a provider whose “yes” is right most of the time but not always.
confirmed_by_gravatarvalidThe server would not say, but a public profile is registered for the address.
mailbox_not_foundinvalidThe mail server says there is no such mailbox.
rejected_unrecognisedinvalidThe mail server refused the address, in words we could not place.
domain_not_servedinvalidThe 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.
syntaxinvalidNot written like an email address.
no_mxinvalidThe domain has no mail server.
dns_brokeninvalidThe domain's DNS does not work, so it cannot receive mail.
accepts_allriskyThe server accepts every address, real or not.
mailbox_fullriskyThe mailbox exists and is full; mail bounces until its owner makes room.
disposable_domaindisposableThe domain belongs to a temporary-address service.
smtp_temporaryunknownThe mail server asked us to come back later.
smtp_throttledunknownThe mail server asked us to slow down.
smtp_blockedunknownThe mail server refused our check.
smtp_unreachableunknownThe mail server did not answer.
catch_all_unconfirmedunknownAccepted, but we could not find out whether the server accepts everything.
dns_errorunknownWe could not look the domain up.
unsupported_addressunknownThe name before the @ uses characters we cannot check.
earlier_answervalid, invalid or riskyNo answer this time; the status is what the mail server said when we last checked.

Credits

Limits

WhatLimit
Requests300 a minute for one account
Single checks waiting at once5 for one account
Addresses in one list100,000
Size of one request12 MB
Results in one page1,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\"." }
StatusMeaning
400The request cannot be read as it is: a field is missing or not what it should be.
401No key, or a key that is wrong, replaced or deleted.
402Not enough credits.
403The key may not do this.
404No such address, or no such list among yours.
413Too large: more than 100,000 addresses, or more than 12 MB.
429Too many requests. Wait as long as Retry-After says.
500Something 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