requests
This tutorial shows how to verify and clean email lists in Python using the ip-api.io batch API. Unlike syntax checkers or DNS-only validators, the API performs a live SMTP handshake with each recipient's mail server to confirm the inbox exists — detecting invalid, disposable, catch-all, and role-based addresses before they damage your sender reputation.
What you'll build
requests library (installed below)
Install the requests library if you don't have it already. Pin the version
in requirements.txt for reproducible installs.
pip install requests
# requirements.txt
requests>=2.28
The batch endpoint accepts an array of up to 100 addresses — pass a single-element list for individual checks. The API performs a live SMTP handshake and returns the result in one response. Store your API key in an environment variable — never hardcode it.
import os
import requests
API_KEY = os.environ["IP_API_IO_KEY"]
resp = requests.post(
"https://ip-api.io/api/v1/email/advanced/batch",
headers={"X-Api-Key": API_KEY},
json={"emails": ["user@example.com"]},
timeout=30,
)
resp.raise_for_status()
results = resp.json()
data = results["user@example.com"]
print(data["reachable"]) # "yes", "no", or "unknown"
print(data["disposable"]) # False
print(data["role_account"]) # False
print(data["smtp"]["catch_all"]) # False
print(data["has_mx_records"]) # True
print(data["suggestion"]) # None, or e.g. "user@gmail.com" for typos
X-Api-Key header with your API key to verify larger lists —
subscribe at ip-api.io/pricing to get started.
For lists larger than 100 addresses, split into chunks of 100 and collect all results before categorizing. The API processes each batch concurrently — a full 100-address batch typically completes in 15–60 seconds depending on SMTP server response times.
import os
import csv
import requests
from itertools import islice
API_KEY = os.environ["IP_API_IO_KEY"]
def _chunks(iterable, size):
it = iter(iterable)
while chunk := list(islice(it, size)):
yield chunk
def verify_emails(emails: list[str]) -> dict:
"""Verify any number of emails, 100 per API request."""
all_results = {}
for chunk in _chunks(emails, 100):
resp = requests.post(
"https://ip-api.io/api/v1/email/advanced/batch",
headers={"X-Api-Key": API_KEY},
json={"emails": chunk},
timeout=90,
)
resp.raise_for_status()
all_results.update(resp.json())
return all_results
def categorize(data: dict) -> str:
if data["reachable"] == "yes" and not data["disposable"]:
return "clean"
if data["reachable"] == "no" or data["disposable"]:
return "invalid"
return "risky"
emails = ["alice@example.com", "test@mailinator.com", "bob@company.org"]
results = verify_emails(emails)
clean = [e for e, d in results.items() if categorize(d) == "clean"]
risky = [e for e, d in results.items() if categorize(d) == "risky"]
invalid = [e for e, d in results.items() if categorize(d) == "invalid"]
print(f"Clean: {len(clean)}, Risky: {len(risky)}, Invalid: {len(invalid)}")
# Write the clean list to a CSV file
with open("clean_list.csv", "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["email"])
writer.writerows([[e] for e in clean])
The CSV endpoint accepts a multipart/form-data POST with a CSV or plain-text
file up to 1MB. The API reads the first column as the email address and
auto-detects header rows. Use requests' files= parameter —
no need to set Content-Type manually.
import os
import requests
API_KEY = os.environ["IP_API_IO_KEY"]
def verify_csv(path: str) -> dict:
"""Upload a CSV file for batch verification.
First column used as email; header row auto-detected; max 1MB per request.
For files larger than 1MB, split and send multiple requests.
"""
with open(path, "rb") as f:
resp = requests.post(
"https://ip-api.io/api/v1/email/advanced/batch/csv",
headers={"X-Api-Key": API_KEY},
files={"file": f},
timeout=90,
)
resp.raise_for_status()
return resp.json()
results = verify_csv("email_list.csv")
print(f"Verified {len(results)} addresses")
# Extract clean addresses
clean = [
email for email, data in results.items()
if data["reachable"] == "yes" and not data["disposable"]
]
print(f"{len(clean)} deliverable addresses")
@ sign it is treated as a header and skipped.
Comma, semicolon, and tab delimiters are all accepted.
A complete EmailListCleaner class with error handling for timeouts,
rate limits, and HTTP errors, plus a method to clean a CSV file in one call.
import os
import csv
import requests
from itertools import islice
API_KEY = os.environ["IP_API_IO_KEY"]
_BATCH_URL = "https://ip-api.io/api/v1/email/advanced/batch"
_CSV_URL = "https://ip-api.io/api/v1/email/advanced/batch/csv"
def _chunks(iterable, size):
it = iter(iterable)
while chunk := list(islice(it, size)):
yield chunk
class EmailListCleaner:
"""Batch email verifier using the ip-api.io SMTP verification API."""
def __init__(self, api_key: str = API_KEY, timeout: int = 90):
self.session = requests.Session()
self.session.headers["X-Api-Key"] = api_key
self.timeout = timeout
def verify_batch(self, emails: list[str]) -> dict:
"""Verify up to 100 emails in one API request.
Returns a dict keyed by email address.
Raises RuntimeError on HTTP errors or rate limiting.
"""
try:
resp = self.session.post(
_BATCH_URL,
json={"emails": emails},
timeout=self.timeout,
)
if resp.status_code == 429:
raise RuntimeError(
"Rate limit reached — upgrade your plan or add an API key"
)
resp.raise_for_status()
return resp.json()
except requests.Timeout:
raise RuntimeError(
"Request timed out (SMTP verification can take up to 60s for 100 addresses)"
)
except requests.HTTPError as exc:
raise RuntimeError(f"API error HTTP {exc.response.status_code}") from exc
def clean_list(
self, emails: list[str]
) -> tuple[list[str], list[str], list[str]]:
"""Verify a list of any size. Returns (clean, risky, invalid)."""
clean, risky, invalid = [], [], []
for chunk in _chunks(emails, 100):
results = self.verify_batch(chunk)
for email, data in results.items():
if data["reachable"] == "yes" and not data["disposable"]:
clean.append(email)
elif data["reachable"] == "no" or data["disposable"]:
invalid.append(email)
else:
risky.append(email)
return clean, risky, invalid
def clean_csv(self, input_path: str, output_path: str) -> int:
"""Read input_path CSV, verify all addresses, write clean ones to output_path.
Returns the number of deliverable addresses written.
"""
with open(input_path, newline="") as f:
rows = list(csv.reader(f))
start = 1 if rows and "@" not in rows[0][0] else 0
emails = [row[0].strip() for row in rows[start:] if row]
clean, _, _ = self.clean_list(emails)
with open(output_path, "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["email"])
writer.writerows([[e] for e in clean])
return len(clean)
# Usage
cleaner = EmailListCleaner()
n = cleaner.clean_csv("raw_list.csv", "clean_list.csv")
print(f"{n} deliverable addresses written to clean_list.csv")
Prefer async Python? Use httpx as a drop-in replacement:
import httpx
import os
API_KEY = os.environ["IP_API_IO_KEY"]
async def verify_emails_async(emails: list[str]) -> dict:
async with httpx.AsyncClient(
headers={"X-Api-Key": API_KEY},
timeout=90,
) as client:
resp = await client.post(
"https://ip-api.io/api/v1/email/advanced/batch",
json={"emails": emails},
)
resp.raise_for_status()
return resp.json()
The batch endpoint returns a JSON object keyed by email address. Each value contains the full verification result including SMTP status, syntax check, and risk flags.
{
"alice@example.com": {
"email": "alice@example.com",
"reachable": "yes",
"syntax": {
"username": "alice",
"domain": "example.com",
"valid": true
},
"smtp": {
"host_exists": true,
"deliverable": true,
"full_inbox": false,
"catch_all": false,
"disabled": false
},
"gravatar": {
"has_gravatar": false,
"gravatar_url": null
},
"suggestion": null,
"disposable": false,
"role_account": false,
"free": false,
"has_mx_records": true
}
}
| Field | Type | Description |
|---|---|---|
reachable |
string | Overall verdict: "yes" (inbox confirmed), "no" (inbox rejected), "unknown" (catch-all or inconclusive) |
disposable |
boolean | Matched against 10,000+ throwaway / temporary email domains |
role_account |
boolean | Role-based address such as admin@, noreply@, support@ |
free |
boolean | Address belongs to a free email provider (Gmail, Hotmail, Yahoo, etc.) |
has_mx_records |
boolean | Domain has valid MX records pointing to a mail server |
suggestion |
string / null | Corrected address for common typos — e.g. "gmial.com" → "gmail.com" |
smtp — live SMTP check results| Field | Type | Description |
|---|---|---|
deliverable |
boolean | Mail server confirmed the inbox exists and will accept messages |
host_exists |
boolean | Mail server is reachable on port 25 |
catch_all |
boolean | Domain accepts all addresses regardless of whether the mailbox exists — inbox cannot be individually confirmed |
full_inbox |
boolean | Mail server responded with a "mailbox full" error |
disabled |
boolean | The mailbox has been disabled or suspended |
syntax — format validation| Field | Type | Description |
|---|---|---|
valid |
boolean | Address passes RFC 5321/5322 syntax rules |
username |
string | Local part of the address (before @) |
domain |
string | Domain part of the address (after @) |
| Category | Condition | What to do |
|---|---|---|
| Clean | reachable == "yes" and disposable == False |
Keep — inbox confirmed and not a throwaway address |
| Risky | reachable == "unknown" or smtp.catch_all == True |
Segment separately — catch-all or inconclusive; monitor bounce rates |
| Invalid | reachable == "no" or disposable == True |
Remove — inbox does not exist or is a disposable address |
Role accounts (role_account == True) and free providers (free == True)
are flagged but not automatically invalid — keep or filter based on your use case.
For cold outreach, filtering role accounts is recommended.
Common questions about Python email validation and the ip-api.io batch API.
Use the requests library to POST to the ip-api.io batch endpoint:
import os, requests
API_KEY = os.environ["IP_API_IO_KEY"]
resp = requests.post(
"https://ip-api.io/api/v1/email/advanced/batch",
headers={"X-Api-Key": API_KEY},
json={"emails": ["user@example.com"]},
timeout=30,
)
data = resp.json()["user@example.com"]
print(data["reachable"]) # "yes" | "no" | "unknown"
print(data["disposable"]) # False
The reachable field reflects a live SMTP handshake with the recipient's
mail server. "yes" means the inbox was confirmed; "no" means
it was rejected; "unknown" means the server is a catch-all.
The ip-api.io API performs a live SMTP handshake — it connects to the recipient's mail server and asks whether the inbox exists, but it never actually delivers a message. This is more accurate than syntax checks or DNS-only validators, which only confirm the domain has MX records, not whether the specific mailbox exists.
resp = requests.post(
"https://ip-api.io/api/v1/email/advanced/batch",
headers={"X-Api-Key": API_KEY},
json={"emails": ["user@example.com"]},
timeout=30,
)
data = resp.json()["user@example.com"]
# smtp.deliverable is True only when the server confirmed the inbox
print(data["smtp"]["deliverable"]) # True
print(data["smtp"]["host_exists"]) # True — MX server is reachable
print(data["has_mx_records"]) # True — domain has MX records
SMTP verification accuracy is 95%+ for non-catch-all domains. The main exception is
catch-all servers, which always accept connections regardless of whether the inbox
exists — check smtp.catch_all to detect them.
Send your list in chunks of up to 100 to the batch API. Categorize results with three buckets:
def categorize(data: dict) -> str:
if data["reachable"] == "yes" and not data["disposable"]:
return "clean"
if data["reachable"] == "no" or data["disposable"]:
return "invalid"
return "risky"
results = verify_emails(["alice@example.com", "fake@mailinator.com"])
clean = [e for e, d in results.items() if categorize(d) == "clean"]
risky = [e for e, d in results.items() if categorize(d) == "risky"]
invalid = [e for e, d in results.items() if categorize(d) == "invalid"]
Use itertools.islice to chunk large lists into 100-address batches.
See step 3 above for the full chunking pattern. For CSV files, use the
POST /api/v1/email/advanced/batch/csv endpoint (step 4).
reachable: "unknown" mean?
reachable: "unknown" means the mail server accepted the SMTP connection
but could not confirm the specific inbox. This is almost always caused by a
catch-all domain — a server configured to accept all incoming email
regardless of whether the mailbox exists.
data = results["user@company.org"]
if data["reachable"] == "unknown":
if data["smtp"]["catch_all"]:
print("Catch-all domain — inbox cannot be confirmed individually")
else:
print("SMTP inconclusive — server was unresponsive or greylisting")
Catch-all addresses are common in enterprise and government domains where the address is often real. A conservative approach is to treat them as risky: keep them in a separate segment and monitor bounce rates before sending at full volume.
Start using IP-API.io to make your website safer and more user-friendly. Keep out unwanted bots, show visitors content that's relevant to where they are, and spot risky IP addresses quickly. It's perfect for making online shopping more personal and keeping your site secure. Get started today with one of the plans!
Explore how IP-API.io can enhance your security, provide robust bot protection, and improve IP geolocation accuracy for your applications.
Contact SupportCustomize your experience with tailored plans that fit your IP security and geolocation needs.
Email Us