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:

FieldWhat it tells you
verdictvalid, invalid, catch_all or unknown
reasonWhy an address is invalid: rejected, disabled, full_inbox, no_mail_server or syntax
mail_server, provider, gatewayThe MX host that answered and who runs it, including security gateways
role, disposable, free_providerFlags for addresses like info@, throwaway inboxes and personal email providers
checked_at, reused, chargedWhen 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:

  1. Check the syntax in the browser so obvious typos are caught instantly.
  2. Accept the signup, then run the verify call in a background job.
  3. 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.
  4. If the verdict is unknown, accept the address and treat it as unconfirmed.

Handle each verdict in code

VerdictSignup formCampaign or CRM import
validAcceptSend
invalidAsk for a corrected addressSuppress
catch_allAcceptSeparate segment, or run a catch-all check
unknownAccept as unconfirmedRetry 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.csv

Files 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 credits

Keep reading