ContactLintAPIMCPPricingFree toolsGet a key

Home / Clean an Email List CSV Before You Send

Clean an Email List CSV Before You Send

Published 2026-10-17

Sending to an old or imported list is how bounce rates climb and sender reputation drops. Before a campaign, run the list through a pre-send check: catch addresses on domains that cannot receive mail, throwaway domains, role addresses like info@, and typos like gmial.com. This guide gives you a script that reads a CSV, checks every unique address with the ContactLint batch endpoint, and writes the same CSV back with a verdict column.

The batch endpoint

POST /v1/email/validate/batch takes up to 50 addresses per request and returns one result per address, in the same order, with the same fields as the single-address endpoint, plus a summary count:

curl -X POST https://contactlint.com/v1/email/validate/batch \
  -H "Authorization: Bearer YOUR_SECRET_KEY" \
  -H "content-type: application/json" \
  -d '{"emails":["jane@gmial.com","sales@stripe.com","bob@mailinator.com"]}'

Each address uses one check. A batch counts as one request against the limit of 60 requests per minute per key, so a list moves up to 50 times faster than calling the single endpoint in a loop. If your key does not have enough checks left for the whole batch, the request fails with 402 and nothing is charged. Addresses that come back unknown because a DNS lookup failed are not billed, so you can send those again later.

The script (Python, no packages to install)

Save this as clean.py, put your secret key in it (get one free at /signup), and run python3 clean.py contacts.csv cleaned.csv. It finds the first column with "email" in its name, removes duplicates and blank cells so you never pay twice for the same address, and waits and retries when it hits the rate limit.

import csv, json, sys, time, urllib.request, urllib.error

KEY = "YOUR_SECRET_KEY"
URL = "https://contactlint.com/v1/email/validate/batch"
src, out = sys.argv[1], sys.argv[2]

with open(src, newline="", encoding="utf-8-sig") as f:
    rows = list(csv.DictReader(f))
col = next(c for c in rows[0] if "email" in c.lower())
unique = sorted({r[col].strip().lower() for r in rows if r[col].strip()})
found = {}

def post(batch):
    body = json.dumps({"emails": batch}).encode()
    headers = {"Authorization": "Bearer " + KEY, "content-type": "application/json"}
    while True:
        try:
            req = urllib.request.Request(URL, data=body, headers=headers)
            with urllib.request.urlopen(req, timeout=30) as r:
                return json.load(r)
        except urllib.error.HTTPError as e:
            if e.code in (429, 409):
                time.sleep(5)
                continue
            raise SystemExit(f"Stopped: HTTP {e.code} {e.read().decode()}")

for i in range(0, len(unique), 50):
    for res in post(unique[i:i + 50])["results"]:
        found[res["input"]] = res

with open(out, "w", newline="", encoding="utf-8") as f:
    w = csv.DictWriter(f, fieldnames=list(rows[0]) + ["verdict", "reason", "did_you_mean"])
    w.writeheader()
    for r in rows:
        res = found.get(r[col].strip().lower(), {})
        why = res.get("reason") or ("disposable" if res.get("disposable") else
                                    "role_address" if res.get("role_address") else "")
        w.writerow({**r, "verdict": res.get("verdict", ""), "reason": why,
                    "did_you_mean": res.get("did_you_mean") or ""})
print(f"Checked {len(found)} unique addresses. Wrote {out}")

Addresses are lowercased before checking, so Jane@Example.com and jane@example.com count as one check. Your original rows and columns are kept, with three columns added at the end.

What to do with each verdict

verdictWhat it meansSuggested action
validGood syntax and the domain has mail serversKeep
riskyDisposable domain, role address, or likely typo (the API sets reason to possible_typo, disposable or role_address, in that order of precedence, and did_you_mean to the suggested fix)Fix typos from did_you_mean if you can confirm them; drop disposable addresses; decide on role addresses for your use case
invalidMalformed, or the domain has no MX or A recordRemove before sending
unknownDNS lookup failed this time (not billed)Run those rows again later

What this does not check

ContactLint does not connect to mail servers, so it cannot confirm that a specific mailbox exists, detect catch-all domains, or spot spam traps. A valid verdict means the address is well formed and its domain can receive mail. If you need mailbox-level verification, see the comparison with mailbox verifiers. For many lists, removing dead domains, typos and throwaway addresses is the cheap first pass that catches the obvious bounces.

What it costs

Every key gets 100 free checks per month, enough to test the script on a sample. After that, credit packs are $9 for 5,000 checks, $35 for 25,000 and $99 for 100,000, and credits do not expire (see pricing). To keep new bad addresses out after the cleanup, validate on form submit or add the signup form widget.

Get a free API key. 100 checks per month, no card required.