How to Verify Email Addresses in Python with CheckMail
Updated · April 2026This guide walks through verifying email addresses in Python with the CheckMail API. You'll see single and batch verification, error handling, a reusable retry helper, and a complete working script. The only dependency is requests, and the whole integration fits in under 50 lines.
Building a verifier from scratch in Python is possible - smtplib, dnspython, a disposable domain list - but cloud providers block port 25, IP reputation is a constant battle, and you'll spend more time maintaining blocklists than shipping your product. The API handles all of that.
§01Prerequisites
You need Python 3.8 or newer and the requests library. Sign up for CheckMail and you'll get 100 free credits instantly - no card required. Keys look like cm_live_... for production and cm_test_... for sandboxing.
python --version # 3.8+
pip install requests
export CHECKMAIL_KEY=cm_live_xxxxxxxxxxxxxxxx
Keep the key out of your source. Read it from the environment, a .env file loaded with python-dotenv, or whatever secret manager your stack uses. Never commit it to git.
§02Your first verification
A single verification is a GET to /v1/verify with the email as a query parameter and a bearer token in the Authorization header:
# verify.py
import os
import requests
KEY = os.environ["CHECKMAIL_KEY"]
response = requests.get(
"https://api.checkmail.dev/v1/verify",
params={"email": "john@example.com"},
headers={"Authorization": f"Bearer {KEY}"},
timeout=15,
)
response.raise_for_status()
data = response.json()
print(data)
Run python verify.py. You'll get back a JSON object describing the address. One credit is deducted per definitive result; unknown verdicts are free. Always pass a timeout - requests will otherwise hang indefinitely on a misbehaving network.
§03Understanding the response
Here's the full shape of a successful response:
{
"email": "john@example.com",
"status": "valid",
"checks": {
"syntax": true,
"mx_found": true,
"smtp_valid": true,
"catch_all": false,
"disposable": false,
"role_based": false,
"free_provider": false
},
"suggestion": null,
"mx_host": "aspmx.l.google.com",
"cached": false,
"ms": 1243
}
The status field is the one you'll branch on most of the time:
valid- mailbox exists and will accept mail. Safe to send.invalid- bad syntax, no MX records, or the mail server rejected the address.catch_all- the domain accepts every address, so we can't be sure. Lower confidence.disposable- a throwaway service like Mailinator. Usually reject at signup.unknown- transient failure or greylisting. Retry later. Not charged.
The suggestion field catches typos - enter john@gnail.con and you'll get "john@gmail.com" back. The full schema is in the docs.
§04Batch verification
For verifying lists - newsletter imports, lead cleanup, periodic hygiene jobs - use the batch endpoint. Up to 500 addresses in one request:
def verify_batch(emails):
response = requests.post(
"https://api.checkmail.dev/v1/verify/batch",
json={"emails": emails},
headers={
"Authorization": f"Bearer {KEY}",
"Content-Type": "application/json",
},
timeout=60,
)
response.raise_for_status()
return response.json()
payload = verify_batch([
"alice@acme.com",
"bob@bouncy.test",
"carol@example.org",
])
for r in payload["results"]:
print(f"{r['email']} → {r['status']}")
print(f"Charged: {payload['charged']} credits")
Each result matches the single-verify schema. The charged field reports the real deduction - unknown results are free, so a batch of 500 can legitimately be billed at 487. Chunk your list in the caller: submitting more than 500 in one request returns 400 batch_too_large. A batch takes longer than a single request, so raise the timeout accordingly.
§05Error handling and retries
Split failures into two buckets. Client errors (4xx) are your problem - bad key, malformed request, quota exhausted - and retrying won't help. Server errors (5xx) and rate limits (429) are transient and should be retried with exponential backoff:
import time
from requests import HTTPError
def verify_with_retry(email, max_attempts=4):
for attempt in range(1, max_attempts + 1):
response = requests.get(
"https://api.checkmail.dev/v1/verify",
params={"email": email},
headers={"Authorization": f"Bearer {KEY}"},
timeout=15,
)
if response.ok:
return response.json()
# Permanent failure - don't retry
if 400 <= response.status_code < 500 and response.status_code != 429:
raise HTTPError(f"Client error {response.status_code}: {response.text}")
# Transient - back off and retry
delay = min(2 ** (attempt - 1), 10)
time.sleep(delay)
raise RuntimeError(f"Gave up after {max_attempts} attempts")
The backoff is 1s, 2s, 4s, 8s - capped at 10. If you prefer, tenacity offers decorator-based retries with jitter baked in, but the hand-rolled version is easy to read and has no extra dependencies. At scale, add jitter (delay + random.uniform(0, 0.5)) so retries don't stampede.
§06Best practices
- Verify at signup. Check the address the moment the user submits the form, server-side, before you create the user record. Bad data stopped at the door beats bad data you clean up later.
- Don't block the UI. A verification call averages 1–2 seconds. Accept the signup optimistically and mark it pending-verification, or fire the call async and render the result inline rather than blocking form submission.
- Cache results. We cache internally and mark
cached: truein the response, but you should still store the verdict in your own database. Don't re-verify the same address twice in one day. - Keep the key server-side. Never bundle your API key into a browser-shipped JS file or mobile app. If you need client-side hints, proxy through your backend and rate-limit per session.
- Handle
unknowngracefully. It means the upstream mail server was temporarily unreachable, not that the address is bad. Retry in 10 minutes. - Use
suggestion. When it's set, prompt the user: "Did you mean john@gmail.com?" It recovers an embarrassing number of typos. - Reuse a
requests.Session. In scripts that fire many requests, a Session reuses the TCP connection toapi.checkmail.devand meaningfully cuts latency.
§07Complete working example
A drop-in script that takes a list of addresses, batches them, retries transient failures, prints a summary, and flags typo suggestions. Save as verify_list.py:
# verify_list.py
import os
import sys
import time
from collections import Counter
import requests
API = "https://api.checkmail.dev/v1"
KEY = os.environ.get("CHECKMAIL_KEY")
BATCH_SIZE = 500
if not KEY:
print("Set CHECKMAIL_KEY in the environment", file=sys.stderr)
sys.exit(1)
session = requests.Session()
session.headers.update({
"Authorization": f"Bearer {KEY}",
"Content-Type": "application/json",
})
def verify_chunk(emails, attempt=1):
response = session.post(
f"{API}/verify/batch",
json={"emails": emails},
timeout=60,
)
if response.ok:
return response.json()
if (response.status_code == 429 or response.status_code >= 500) and attempt < 4:
time.sleep(2 ** (attempt - 1))
return verify_chunk(emails, attempt + 1)
raise RuntimeError(f"{response.status_code}: {response.text}")
def verify_all(emails):
results = []
charged = 0
for i in range(0, len(emails), BATCH_SIZE):
chunk = emails[i:i + BATCH_SIZE]
page = verify_chunk(chunk)
results.extend(page["results"])
charged += page["charged"]
print(f"Processed {len(results)} / {len(emails)}")
return results, charged
if __name__ == "__main__":
emails = [
"alice@acme.com",
"bob@example.org",
"typo@gnail.con",
"throwaway@mailinator.com",
]
results, charged = verify_all(emails)
summary = Counter(r["status"] for r in results)
for r in results:
if r.get("suggestion"):
print(f"{r['email']} → did you mean {r['suggestion']}?")
print(dict(summary))
print(f"Credits charged: {charged}")
That's a production-ready verification pipeline. Wire it into a Celery task, an Airflow DAG, or a plain cron job and you're done.
§08Next steps
Grab an API key and start with 100 free credits. $2 per 1,000 after that. Credits never expire. For the full API reference - domain intelligence, account endpoints, auto-topup - see the docs.
Questions? mail@checkmail.dev