All posts

Bulk-Verify a CSV of Emails Through an API, With a Resumable Script

··7 min read

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.

  1. 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.
  2. 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.
  3. Retry only what is temporary. 408, 429 and 5xx get exponential backoff with jitter, and Retry-After wins if it's longer. Anything else, such as a 401 for a bad key, is recorded as error-401 straight away.
  4. Checkpoint every result. Each answer is appended to a .progress.jsonl file and flushed. A re-run skips everything already in it.
  5. 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 WORKERS repeat 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-later instead of guessing, and running the script again re-checks only that row, because retry-later results 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 unknown separate from undeliverable, 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.