Bulk-Verify a CSV of Emails Through an API, With a Resumable Script
To bulk-verify a CSV through an email verification API, dedupe the addresses and send each unique one to the vendor's single-check endpoint through a rate limiter. Honour Retry-After on 429s and write each result to a checkpoint file as it arrives, so a crash costs nothing. Then output a new CSV with your original columns plus a verification column. The script below does all of that in about 140 lines of Python.
It was tested on 6 October 2026 with Python 3.14 and requests 2.34.2 against a local mock API that enforces 10 requests per second and 3 in flight, so the numbers measure the script, not a vendor.
Bulk endpoint or single-check loop?
For a one-off clean of a big list, the vendor's bulk upload is usually less work. A loop over the single-check API makes sense when verification is one step in your own pipeline.
Many vendors offer a job-based bulk API. NeverBounce's /jobs/create, for example, takes a remote file URL or the data inline, returns a job_id, and states that "verification results are not returned in the response". You poll or get a callback, then download the results.
| Bulk/job endpoint | Single-check loop (this script) | |
|---|---|---|
| Pacing | Vendor's problem | Yours |
| Results | All at once, when the job finishes | Row by row, as they arrive |
| Partial failure | Re-run the job | Resume from a checkpoint |
| Fits a pipeline | Upload, wait, download | Any step, any language |
| Rate limits | Separate, usually generous | Single-check limits apply |
Bulk and single-check endpoints have different limits and sometimes different prices, so check both in your vendor's docs. If you'd rather not run code at all, most verification services, SimpleVerifier included, accept a CSV upload in the browser. SimpleVerifier also has a REST API on its paid plans (see the API docs), and the web upload takes up to 100,000 rows per file.
What the script has to get right
Five things separate a script you can trust from a for-loop.
- Dedupe before paying. Trim and lower-case, then verify each unique address once. Results map back to every row, so duplicates still get a status.
- Pace, don't burst. A shared pacer spaces request starts evenly across threads. On a 429 it pushes back every worker, not just the one that hit the limit.
- Retry only what is temporary. 408, 429 and 5xx get exponential backoff with jitter, and
Retry-Afterwins if it's longer. Anything else, such as a 401 for a bad key, is recorded aserror-401straight away. - Checkpoint every result. Each answer is appended to a
.progress.jsonlfile and flushed. A re-run skips everything already in it. - Never touch the input. The output is a new file with every original column plus
verification.
The reasoning behind the pacing and backoff is in handling email verification API rate limits. This is the batch version of the same pattern.
The script
Set VERIFY_URL, VERIFY_KEY, PER_SECOND and WORKERS, then run python bulk_verify.py contacts.csv verified.csv.
import csv
import json
import os
import random
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from email.utils import parsedate_to_datetime
import requests
VERIFY_URL = os.environ["VERIFY_URL"]
VERIFY_KEY = os.environ["VERIFY_KEY"]
PER_SECOND = float(os.environ.get("PER_SECOND", "5"))
WORKERS = int(os.environ.get("WORKERS", "3"))
RETRYABLE = {408, 429, 500, 502, 503, 504}
class Pacer:
"""Spaces request starts evenly; pause() pushes every worker back after a 429."""
def __init__(self, per_second):
self.interval = 1.0 / per_second
self.next_slot = time.monotonic()
self.lock = threading.Lock()
def wait(self):
with self.lock:
slot = max(time.monotonic(), self.next_slot)
self.next_slot = slot + self.interval
time.sleep(max(0.0, slot - time.monotonic()))
def pause(self, seconds):
with self.lock:
self.next_slot = max(self.next_slot, time.monotonic() + seconds)
def retry_after_seconds(value):
if not value:
return 0.0
if value.strip().isdigit():
return float(value)
try:
return max(0.0, parsedate_to_datetime(value).timestamp() - time.time())
except (TypeError, ValueError):
return 0.0
_local = threading.local()
def session():
# requests.Session is not documented as thread-safe, so keep one per worker.
if not hasattr(_local, "s"):
_local.s = requests.Session()
_local.s.headers["Authorization"] = f"Bearer {VERIFY_KEY}"
return _local.s
def verify(pacer, email, max_attempts=5):
for attempt in range(max_attempts):
pacer.wait()
try:
res = session().get(VERIFY_URL, params={"email": email}, timeout=20)
except requests.RequestException:
res = None
if res is not None and res.ok:
return res.json().get("result", "unknown")
if res is not None and res.status_code not in RETRYABLE:
return f"error-{res.status_code}"
wait = max(
retry_after_seconds(res.headers.get("Retry-After") if res is not None else None),
random.uniform(0, min(30, 0.5 * 2**attempt)),
)
if res is not None and res.status_code == 429:
pacer.pause(wait)
time.sleep(wait)
return "retry-later"
def load_done(path):
done = {}
if os.path.exists(path):
with open(path, encoding="utf-8") as f:
for line in f:
row = json.loads(line)
done[row["email"]] = row["result"]
return done
def main(src, dst, column="email"):
checkpoint = dst + ".progress.jsonl"
done = load_done(checkpoint)
with open(src, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
fieldnames = reader.fieldnames
rows = list(reader)
if column not in fieldnames:
sys.exit(f"No '{column}' column. Found: {fieldnames}")
def key(row):
return row[column].strip().lower()
todo = sorted({key(r) for r in rows if "@" in key(r)} - done.keys())
print(f"{len(rows)} rows, {len(todo)} unique addresses left to verify")
pacer = Pacer(PER_SECOND)
write_lock = threading.Lock()
with open(checkpoint, "a", encoding="utf-8") as log:
def work(email):
result = verify(pacer, email)
with write_lock:
if result != "retry-later":
log.write(json.dumps({"email": email, "result": result}) + "\n")
log.flush()
done[email] = result
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
list(pool.map(work, todo))
with open(dst, "w", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames + ["verification"])
writer.writeheader()
for row in rows:
k = key(row)
row["verification"] = done.get(k, "invalid-syntax" if "@" not in k else "retry-later")
writer.writerow(row)
counts = {}
for value in done.values():
counts[value] = counts.get(value, 0) + 1
print(json.dumps(counts, sort_keys=True))
if __name__ == "__main__":
main(*sys.argv[1:])
Two lines are vendor-specific. The request is GET VERIFY_URL?email=... with a bearer token, and the status is read from a field called result. Adapt both to your vendor's documented endpoint, auth method and response field. Email verification APIs compared lists them for nine vendors. Set PER_SECOND and WORKERS to about 80% of the vendor's published rate and concurrency limits.
The script lower-cases the whole address for deduplication. That's safe in practice, because the large providers treat local parts as case-insensitive, but the address sent to the API is the lower-cased one. The only local syntax check is "contains an @". Pre-filter with the stricter checks in removing invalid emails from a CSV if your list is messy, since there's no point paying to verify n/a. The file is opened as utf-8-sig, so CSVs saved by Excel with a byte-order mark still have a clean email header.
What the test run showed
82 rows, 80 unique addresses, verified with zero 429s. After a mid-run interrupt, the re-run picked up where it stopped.
The test file had 80 addresses, a duplicate, a row reading not-an-email, and some addresses with stray spaces and capitals.
| Run | Settings | Result |
|---|---|---|
| Clean run | 8/s, 3 workers | 80 verified, 0 rate-limited, about 10.5 s |
| Interrupted after 4 s, then re-run | 8/s, 3 workers | 38 checkpointed; re-run said "42 unique addresses left" and finished them |
| Overdriven, twice | 20/s, 6 workers | Both runs hit 429s (68 between them). One finished cleanly, one ended with 1 address as retry-later |
Two details from those runs are worth knowing.
- The interrupt cost two extra calls. The mock served 82 requests for 80 addresses, because two were in flight when the process was killed and hadn't been checkpointed. Expect to pay for up to
WORKERSrepeat checks per crash. - Overdriving the limit is how you get gaps. At 20 per second against a 10-per-second limit, one address used up all five attempts. The script recorded it as
retry-laterinstead of guessing, and running the script again re-checks only that row, becauseretry-laterresults aren't checkpointed.
The output keeps every row. The duplicate got the same status as its first occurrence, and not-an-email was marked invalid-syntax without an API call:
name,email,verification
Person 0, BAD0@EXAMPLE.COM ,undeliverable
Person 1,user1@example.com,deliverable
...
Dup,user1@example.com,deliverable
Broken,not-an-email,invalid-syntax
How long it will take
Time is unique addresses divided by your sustained rate. Deduplicate first, because it is free.
| Unique addresses | At 5/s | At 25/s |
|---|---|---|
| 10,000 | 33 minutes | 7 minutes |
| 100,000 | 5.6 hours | 67 minutes |
| 1,000,000 | 56 hours | 11 hours |
Those figures assume no retries. Run the second pass for unknown results a few hours later, because the greylisting that causes many of them is designed to make immediate retries fail.
What to do with the results
Remove undeliverable, keep deliverable, and put everything else in a separate segment.
| Status | Action |
|---|---|
| deliverable | Keep |
| undeliverable | Remove. Sending will hard bounce |
| unknown / retry-later | Re-check in a few hours |
| risky / catch-all | Separate segment, send in small batches |
| invalid-syntax | Fix by hand or remove |
| error-4xx | Fix your key or plan, then re-run |
Each vendor names these statuses differently. Email verification statuses explained maps them, and should you send to catch-all emails covers the hardest bucket. To estimate how bad a list is before paying for a full run, the free email list health check samples it.
The takeaway
- For one-off cleans of large lists, try the vendor's bulk endpoint first.
- In your own loop, dedupe, pace at about 80% of the published limit, honour
Retry-After, and retry only temporary errors. - Checkpoint every result so a crash costs at most a few repeat calls.
- Write a new file. Keep
unknownseparate fromundeliverable, and re-check it later.
Common questions
How do I verify a CSV of email addresses with an API?
Read the file, trim and lower-case the addresses, remove duplicates, and send each unique address to the vendor's single-check endpoint through a rate limiter. Save every result as it arrives so a crash doesn't lose work, then write a new CSV with your original columns plus a verification column. For very large files, the vendor's bulk upload endpoint is usually simpler.
Should I use a vendor's bulk endpoint or loop over the single-check API?
Use the bulk endpoint for one-off cleans of large lists. You upload the file once, poll the job and download results, and the vendor handles pacing. Loop over the single-check API when you need results row by row, want to combine verification with your own processing, or the vendor's bulk endpoint doesn't fit your pipeline.
How long does it take to verify 100,000 emails through an API?
It depends on the rate limit you're allowed. At a steady 5 requests per second, 100,000 unique addresses take about 5.6 hours, and at 25 per second about 67 minutes. Deduplicating first often shrinks the list, and unknown results add a second, smaller pass later.
What should I do with unknown results from a bulk verification?
Keep them separate. Unknown usually means the recipient's server was slow or greylisted the check, not that the address is bad. Re-verify them a few hours later. If they're still unknown, send to them in small batches or leave them out of large campaigns.
Check your list before you send
Real mailbox checks over SMTP, and nothing is sent to the address. Sign in with Google and your first 100 email checks are free, no card. Plans for bigger lists are on the pricing page.