Email verification API: how to verify emails in your code
How to verify emails with an API: where to call it, curl, Python and Node examples for POST /v1/verify, handling 402 and unknown results, and bulk jobs.
BounceOwl team8 min read
To verify an email with an API, send the address to a verification endpoint, wait for the live check against the recipient's mail server, and act on the verdict it returns: valid, invalid, catch-all or unknown. With BounceOwl that is one call to POST /v1/verify with a Bearer API key. The rest of this guide covers where to put that call, how to handle the answers and errors, and when to switch to a bulk upload instead.
When to verify through an API
An API makes sense when addresses arrive one at a time, or as part of an automated flow you already run:
- Signup and lead forms. Catch typos and dead domains before they become accounts nobody can reach.
- CRM imports and syncs. Check new contacts as they are created, so bad records don't spread to your sequencer, billing tool and reports.
- Pre-send steps in your own pipeline. Verify each recipient right before it enters a campaign queue.
- AI agents and scripts. Let a tool check an address before it drafts or sends anything.
If you have one large file to clean, a manual upload is simpler. The bulk email verification page handles CSV, TXT and XLSX files, and the API has a bulk endpoint for the same job, covered below.
Authentication
All requests go to https://api.bounceowl.com with an API key in the Authorization header. Create and revoke keys in the app. A key is shown once, so store it in an environment variable or your secrets manager straight away. The examples below read it from BOUNCEOWL_KEY.
Verify one address with curl
curl https://api.bounceowl.com/v1/verify \
-H "Authorization: Bearer $BOUNCEOWL_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "maya.chen@lumenforge.io"}'The response comes back in the same request once the check finishes:
{
"id": 4182,
"email": "maya.chen@lumenforge.io",
"verdict": "valid",
"reason": "",
"checked_at": "2026-11-06T14:02:11Z",
"mailbox": "accepts_mail",
"mail_server": "aspmx.l.google.com",
"provider": "google_workspace",
"gateway": "",
"method": "smtp",
"duration_ms": 2840,
"catch_all": false,
"disposable": false,
"role": false,
"free_provider": false,
"reused": false,
"charged": 1,
"catchall_status": "",
"catchall_verdict": ""
}The fields you will use most:
| Field | What it tells you |
|---|---|
verdict | valid, invalid, catch_all or unknown |
reason | Why an address is invalid: rejected, disabled, full_inbox, no_mail_server or syntax |
mail_server, provider, gateway | The MX host that answered and who runs it, including security gateways |
role, disposable, free_provider | Flags for addresses like info@, throwaway inboxes and personal email providers |
checked_at, reused, charged | When the live check ran, whether a recent result was reused, and the credits it cost |
If anyone on BounceOwl checked the same address in the last 7 days, you get that result with its original checked_at, reused: true and charged: 0.
Python example
import os
import requests
API = "https://api.bounceowl.com"
HEADERS = {"Authorization": "Bearer " + os.environ["BOUNCEOWL_KEY"]}
class OutOfCredits(Exception):
pass
def verify(email):
r = requests.post(
API + "/v1/verify",
json={"email": email},
headers=HEADERS,
timeout=150,
)
if r.status_code == 402:
body = r.json()
raise OutOfCredits(f"need {body.get('needed')}, have {body.get('balance')}")
if r.status_code == 429:
return None # rate limited: retry later
r.raise_for_status()
return r.json()
result = verify("maya.chen@lumenforge.io")
if result and result["verdict"] == "valid":
print("send to", result["email"])Node.js example with fetch
const API = "https://api.bounceowl.com";
async function verify(email) {
const res = await fetch(API + "/v1/verify", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.BOUNCEOWL_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(150_000),
});
if (res.status === 402) {
const { needed, balance } = await res.json();
throw new Error("Out of credits: need " + needed + ", have " + balance);
}
if (res.status === 429) return null; // rate limited: retry later
if (!res.ok) throw new Error("Verify failed with status " + res.status);
return res.json();
}
const result = await verify("maya.chen@lumenforge.io");
console.log(result?.verdict);Set a long timeout, and keep it off the critical path
Most checks finish in a few seconds. Some mail servers are slow to answer or make new senders wait, and a check can take up to two minutes. Set your client timeout to at least 150 seconds so you don't cut off a check that is about to return.
That has a design consequence for signup forms: you can't make a user stare at a spinner for two minutes. A pattern that works well:
- Check the syntax in the browser so obvious typos are caught instantly.
- Accept the signup, then run the verify call in a background job.
- If the verdict is invalid, flag the account and ask the user to correct their address the next time they visit, instead of sending mail that will bounce.
- If the verdict is unknown, accept the address and treat it as unconfirmed.
Handle each verdict in code
| Verdict | Signup form | Campaign or CRM import |
|---|---|---|
| valid | Accept | Send |
| invalid | Ask for a corrected address | Suppress |
| catch_all | Accept | Separate segment, or run a catch-all check |
| unknown | Accept as unconfirmed | Retry later; don't send yet |
Unknown means the mail server gave no clear answer, for example a timeout, a block or greylisting. It is not the same as invalid, so don't delete those contacts. Unknown results are always free, so retrying later, for example the next day, costs nothing until you get a real answer.
For catch-all results that matter, send the check IDs to POST /v1/catchall. Answers arrive within minutes; read them with GET /v1/checks/{id}, where catchall_status and catchall_verdict are filled in. Our guide to catch-all email addresses explains when that extra step is worth it.
Handle 402 and 429
When your balance runs out, the API returns 402 with needed and balance in the body. Treat it as a stop signal, not a retry signal: pause the job, alert whoever owns billing, and resume after a top-up. Retrying in a loop just repeats the same 402.
The default limit is 300 verify calls per minute per key. Past that you get 429. Run checks through a small worker pool instead of firing thousands of requests at once, and back off for a short while when you see a 429.
Batch large jobs with /v1/bulk
For thousands of addresses, upload a file instead of looping over single calls. Bulk lists remove duplicates (case-insensitive), show the cost before anything runs, and return unused credits when the list finishes.
# 1. Upload. Nothing is charged yet.
curl https://api.bounceowl.com/v1/bulk \
-H "Authorization: Bearer $BOUNCEOWL_KEY" \
-F "file=@leads.csv"
# 2. Poll until status is ready; read rows_unique and credits_needed.
curl https://api.bounceowl.com/v1/bulk/311 \
-H "Authorization: Bearer $BOUNCEOWL_KEY"
# 3. Start. max_rows is optional and checks only the first rows.
curl -X POST https://api.bounceowl.com/v1/bulk/311/start \
-H "Authorization: Bearer $BOUNCEOWL_KEY" \
-H "Content-Type: application/json" \
-d '{"max_rows": 10000}'
# 4. Poll until status is done, then download the valid rows.
curl "https://api.bounceowl.com/v1/bulk/311/results?verdict=valid" \
-H "Authorization: Bearer $BOUNCEOWL_KEY" -o valid.csvFiles can be up to 50 MB and 500,000 rows. The email column is detected automatically, or you can name it with email_column. The results CSV keeps your original columns and adds bo_verdict, bo_reason, bo_mail_server, bo_provider, bo_catch_all and the other result columns.
Get started
Create a key in the app, then read the endpoint reference in the API docs. The email verification API page summarizes what each endpoint does, and the MCP server gives AI assistants like Claude Code and Cursor the same checks. Pricing is the same for the app, API and MCP: one credit per live answer, with unknown results free. See the pricing page for packs.
Check a list before you send it.
Live checks with evidence. Unknown results are free, and credits never expire.
Start with 100 free creditsKeep reading
7 min read
How to verify an email address (without sending one)
How to verify an email address step by step: check syntax, look up MX records, ask the mail server over SMTP and test for catch-all, all without sending mail.
BounceOwl team
6 min read
What is a catch-all email, and should you send to one?
What a catch-all email domain is, why accept-all servers make verification unreliable, how a made-up address test detects them and how to send to them safely.
BounceOwl team
7 min read
Why do verified emails bounce? Six causes and how to fix them
Why verified emails still bounce: stale results, catch-all domains, security gateways, full mailboxes and greylisting, plus practical steps to cut bounces.
BounceOwl team