Skip to content

API · Beginner · 5 min read

Verify a list of emails in Python with the batch endpoint

Send up to 50 addresses per request from Python, stay inside the 10-per-minute rate limit, collect processing results and write a clean CSV at the end.

Jul 16, 2026 · SimpleCold team

A Python function that posts up to 50 emails to the batch endpoint and waits when it gets a 429 rate-limit response

The batch endpoint sits between the single and bulk endpoints. You send a JSON array of addresses and get back results in the same response, with no file upload and no job to poll. It suits scripts, cron jobs and small syncs: a few hundred new leads a day, last week's sign-ups, a list someone pasted into a spreadsheet.

This guide builds a Python script that reads addresses from a CSV, verifies them in batches, follows up on slow servers, and writes the results back out.

When to use batch

You haveUse
One address at a time, often from a formGET /api-validation/single
Tens to a few thousand addresses in a scriptPOST /api-validation/batch
A whole CSV file, thousands of rows or morePOST /api-validation/bulk

Batch has two limits: up to 50 addresses per request by default, and 10 requests per minute. That works out to about 500 addresses a minute. If your list is larger than a few thousand rows, upload it as a file instead. The bulk job guide shows how.

The request

curl -s https://simplecold.com/api/v1/api-validation/batch \
  -H "Authorization: Bearer $SIMPLECOLD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"emails": ["[email protected]", "[email protected]", "[email protected]"]}'

The response payload is an array with one result per address, each in the same shape as the single endpoint returns:

{
  "code": "SUCCESS",
  "message": "Successfully performed operation.",
  "payload": [
    {
      "email": "[email protected]",
      "domain": "example.com",
      "status": "mailbox_found",
      "statusMessage": "The mailbox exists and accepts mail.",
      "category": "deliverable",
      "failType": "none",
      "mxLocation": "US",
      "siteLocation": "US",
      "siteLanguage": "EN",
      "suggestion": null
    }
  ],
  "requestId": "..."
}

Two details matter for your code. First, results come back in the order you sent them, one per address, but the email field holds the normalized form: trimmed and lowercased. A duplicate inside one request is checked and charged once, though each copy still gets a result. Matching on the lowercased address is the safest way to join results back to your rows. Second, an address that fails the basic format check fails the whole request with a 400, so clean obvious junk out before sending.

Install and set up

pip install requests
export SIMPLECOLD_API_KEY="your-key"

Assume an input file leads.csv with an email column and any other columns you like.

The script

# verify_list.py
import csv
import os
import re
import sys
import time

import requests

API = "https://simplecold.com/api/v1"
KEY = os.environ["SIMPLECOLD_API_KEY"]
BATCH_SIZE = 50
MIN_SECONDS_BETWEEN_BATCHES = 6.5  # 10 requests per minute, with a little slack

session = requests.Session()
session.headers.update({"Authorization": f"Bearer {KEY}"})

# Cheap local filter so one malformed row doesn't fail a whole batch.
LOOKS_LIKE_EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")


def post_batch(emails):
    """Send one batch, retrying on 429 and 5xx. Returns a list of results."""
    for attempt in range(5):
        res = session.post(f"{API}/api-validation/batch", json={"emails": emails}, timeout=60)
        if res.status_code == 429:
            wait = int(res.headers.get("Retry-After") or res.headers.get("RateLimit-Reset") or 60)
            print(f"rate limited, waiting {wait}s", file=sys.stderr)
            time.sleep(wait)
            continue
        if res.status_code >= 500:
            time.sleep(2 ** attempt)
            continue
        body = res.json()
        if not res.ok:
            raise RuntimeError(f"{res.status_code}: {body.get('message')}")
        return body["payload"]
    raise RuntimeError("batch failed after retries")


def fetch_pending(email):
    """Ask for the final result of a 'processing' address. No extra credit is used."""
    res = session.get(f"{API}/api-validation/single/result", params={"email": email}, timeout=30)
    if res.status_code == 404:
        return None  # nothing pending for this address
    res.raise_for_status()
    return res.json()["payload"]


def main(src, dst):
    with open(src, newline="", encoding="utf-8-sig") as f:
        reader = csv.DictReader(f)
        rows = list(reader)
        fieldnames = reader.fieldnames

    emails = []
    seen = set()
    for row in rows:
        e = (row.get("email") or "").strip()
        if e and LOOKS_LIKE_EMAIL.match(e) and e.lower() not in seen:
            seen.add(e.lower())
            emails.append(e)

    results = {}
    last = 0.0
    for i in range(0, len(emails), BATCH_SIZE):
        chunk = emails[i : i + BATCH_SIZE]
        delay = MIN_SECONDS_BETWEEN_BATCHES - (time.monotonic() - last)
        if delay > 0:
            time.sleep(delay)
        last = time.monotonic()
        for r in post_batch(chunk):
            results[r["email"].lower()] = r
        print(f"verified {min(i + BATCH_SIZE, len(emails))}/{len(emails)}", file=sys.stderr)

    # Follow up on slow mail servers.
    pending = [e for e, r in results.items() if r["status"] == "processing"]
    for round_ in range(6):
        if not pending:
            break
        time.sleep(30)
        still = []
        for e in pending:
            r = fetch_pending(e)
            if r is None or r["status"] == "processing":
                still.append(e)
            else:
                results[e] = r
            time.sleep(0.7)  # stay under 100 requests per minute
        pending = still

    out_fields = fieldnames + ["category", "status", "statusMessage", "suggestion"]
    with open(dst, "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=out_fields)
        writer.writeheader()
        for row in rows:
            r = results.get((row.get("email") or "").strip().lower(), {})
            writer.writerow({
                **row,
                "category": r.get("category", "not_checked"),
                "status": r.get("status", ""),
                "statusMessage": r.get("statusMessage", ""),
                "suggestion": r.get("suggestion") or "",
            })


if __name__ == "__main__":
    main(sys.argv[1], sys.argv[2])

Run it:

python verify_list.py leads.csv leads_verified.csv

What the script is doing

Local de-duplication. The script only sends each address once, case-insensitively. Every original row still gets a result in the output, because the lookup at the end uses the lowercased address.

Pacing. Ten requests per minute means one every six seconds. The script spaces batches 6.5 seconds apart, so it rarely sees a 429 at all. If it does, it reads Retry-After or RateLimit-Reset and waits. The error handling guide explains the headers.

Slow servers. Some mail servers take longer than the API waits, which is about 25 seconds for a batch. Those addresses come back with status: "processing" and category: "pending". The script collects them and asks GET /api-validation/single/result every 30 seconds, for up to three minutes. These follow-up calls don't use credits. Anything still processing after that is written out as pending, and you can run the follow-up again later. The processing results guide covers longer waits.

Rows the script skips. Empty cells and strings that clearly aren't addresses are marked not_checked instead of being sent. Sending them would get a 400 for the whole batch.

Reading the output

Open leads_verified.csv and filter on category:

  • deliverable: safe to send.
  • risky: real domain, but something stops a confident yes. Check status to see why: accept_all is a catch-all domain, role_address is an address like info@, disposable is a throwaway inbox.
  • undeliverable: remove these. statusMessage says why, for example This mailbox does not exist.
  • pending: the mail server hadn't answered when the script stopped waiting. Collect it later rather than deleting.

There is no unknown category. When a server won't confirm an address, the result is risky with the status no_response. That's a finished answer and it used a credit, so asking again straight away won't change it.

If suggestion has a value, the domain looked misspelled. Fix the address in your source system before re-checking it. The category mapping guide has a full send-or-suppress table.

Running it on a schedule

The script is idempotent, so it is safe to run from cron. A common setup is a nightly job that exports yesterday's new contacts from your CRM, verifies them, and writes category back as a field. If your nightly export grows past a few thousand rows, switch to the bulk endpoint: one upload replaces dozens of batch calls, and your original columns come back in the result file. See cleaning a CRM export for that workflow.

The batch endpoint reference is in the API docs, and the email verification API page summarises all three endpoints.

Check your list before you send it

Start with 200 free credits. Pay per credit after that.