Moving e-mail verification from Hunter or Apollo to Cold Leads
- Who it is for
- Teams that verify e-mail with Hunter or Apollo today
- The problem
- Verification code is full of vendor-specific parameters and status values. Swapping the endpoint without mapping them quietly changes which addresses a pipeline sends to.
- The solution
- Map every parameter and response field to its Cold Leads counterpart, keep what Cold Leads does not replace (people data, domain search) where it is, and use a small adapter so existing code keeps its shape.
- What you get
- A field-by-field mapping, a Python adapter that returns Hunter-style fields from Cold Leads, and a Cold Leads cost table for 1,000, 10,000 and 50,000 verifications a month.
Addresses, IDs and results in the examples are illustrative. example.com is reserved for documentation, so a real check of these addresses returns invalid.
What moves and what stays
- Moves: single-address checks (Hunter Email Verifier) to POST /api/v1/verify, lists to POST /api/v1/verify/bulk (up to 5,000 addresses per job), and name-plus-domain guesses (Hunter Email Finder) to POST /api/v1/find.
- Stays: Hunter's Domain Search and the web sources behind its results, and Apollo's People Search, People Enrichment and Bulk People Enrichment. Cold Leads has no database of people or companies; GET /api/v1/leads searches only the contacts in your own Cold Leads CRM.
- Apollo's public API has no standalone verification endpoint; email_status arrives with the person data. If you keep Apollo for data, you can check the addresses it returns with Cold Leads before sending.
Email Verifier to POST /api/v1/verify
| Hunter | Cold Leads | Note |
|---|---|---|
| GET /v2/email-verifier?email=… | POST /api/v1/verify with a JSON body {email} | Cold Leads takes a POST with a JSON body. |
| api_key query parameter, X-API-KEY header or Authorization: Bearer | x-api-key header or Authorization: Bearer | Keys in the query string are not accepted. |
| No time parameter; 202 while the check is still running | timeout_ms, 1,000 to 30,000 | Cold Leads answers when the check is done or the budget is spent (then risky, reason timeout). |
| data.status valid | status valid with the last reason ok | valid with smtp_unreachable means the mailbox was not checked. |
| data.status invalid | status invalid, reason mailbox_missing, no_mx or syntax | |
| data.status accept_all | status risky, reason catch_all, catch_all true | |
| data.status disposable | disposable true (status risky, or invalid when the domain has no MX) | Built-in list of 40 disposable-mail domains. |
| data.status webmail | No equivalent | Webmail addresses are checked like any other. |
| data.status unknown | status risky, reason smtp_unknown or timeout | |
| data.score | score (0 to 100) | Different scale; decide on reasons, not on the number. |
| data.regexp | reasons contains syntax when the syntax check fails | |
| data.mx_records | mx: the first MX host, or null when the domain has neither an MX nor an A record (reason no_mx) | |
| data.smtp_server | smtp_unreachable when no mail server could be reached over SMTP | ok, catch_all and mailbox_missing mean a mail server answered the recipient check. |
| data.smtp_check | last reason ok or catch_all | A mail server accepted the address. |
| data.accept_all | catch_all | Meaningful only when a mail server answered (last reason ok, catch_all or mailbox_missing). |
| data.gibberish, data.block, data.sources | No equivalent |
Email Finder to POST /api/v1/find
| Hunter | Cold Leads | Note |
|---|---|---|
| domain | domain | A URL is reduced to its host (https://www.example.com/about becomes example.com). |
| company | No equivalent | A domain is required. |
| first_name, last_name | first, last | Accents are removed and other non-Latin characters dropped, so transliterate names first. |
| full_name | name | Split at the first space into first and last. |
| linkedin_handle, max_duration | No equivalent | |
| data.email | null when no candidate could be built, for example when the domain has no MX. | |
| data.score | confidence (0 to 100) | |
| data.verification.status | method | verified only when a mail server confirmed the mailbox; pattern for a guess from common address patterns; none when nothing was found. |
| data.accept_all | catch_all | |
| data.position, twitter, linkedin_url, phone_number, company, sources | No equivalent | |
| (none) | candidates | Addresses built from common patterns, each with the status of its check when it was checked. |
A drop-in adapter
If your code branches on Hunter's field names, these two functions return the same shape from Cold Leads, plus the raw answer under coldleads. The mapping is deliberately conservative: an address whose mailbox was not checked (smtp_unreachable) becomes unknown, not valid.
# pip install httpx
# Drop-in helpers for code written against Hunter's email-verifier and email-finder.
# They call Cold Leads and return Hunter-style fields, plus the raw Cold Leads answer.
import os
import httpx
cold = httpx.Client(
base_url="https://coldleads.app/api/v1",
headers={"x-api-key": os.environ["COLDLEADS_API_KEY"]}, # the key goes in a header, never in the URL
timeout=40.0,
)
# a mail server answered the SMTP probe only when the last reason is one of these
PROBED = {"ok", "catch_all", "mailbox_missing"}
def email_verifier(email: str) -> dict:
r = cold.post("/verify", json={"email": email, "timeout_ms": 20000})
r.raise_for_status()
c = r.json()
last = c["reasons"][-1]
if c["disposable"] and last not in ("syntax", "no_mx"):
status = "disposable"
elif last == "ok":
status = "valid"
elif last == "catch_all":
status = "accept_all"
elif last in ("mailbox_missing", "no_mx", "syntax"):
status = "invalid"
else:
# smtp_unreachable (mailbox not checked), smtp_unknown or timeout: not verified
status = "unknown"
return {
"email": c["email"],
"status": status,
"score": c["score"],
"regexp": last != "syntax",
"disposable": c["disposable"],
"mx_records": c["mx"] is not None,
"smtp_check": last in ("ok", "catch_all"),
"accept_all": c["catch_all"] if last in PROBED else None, # None: not tested
"coldleads": c,
}
def email_finder(domain: str, first_name: str = "", last_name: str = "", full_name: str = "") -> dict:
body = {"domain": domain, "first": first_name, "last": last_name}
if full_name and not (first_name or last_name):
body = {"domain": domain, "name": full_name}
r = cold.post("/find", json=body)
r.raise_for_status()
c = r.json()
return {
"email": c["email"],
"score": c["confidence"],
"accept_all": c["catch_all"],
# Cold Leads says "verified" only when a mail server confirmed the mailbox; a pattern guess is not verified
"verification": {"status": "valid" if c["method"] == "verified" else None},
"coldleads": c,
}
if __name__ == "__main__":
print(email_verifier("anna@example.com"))Behaviour differences to plan for
- Error codes: Cold Leads answers 429 for its rate limit (rate_limited, with Retry-After: 60) and for too many open bulk jobs (too_many_jobs), and 402 no_credits when credits run out. Hunter documents 403 for its rate limit and 429 for its usage limit, so the error handling has to be rewritten, not renamed.
- Rate limit: 120 requests per minute per key. For lists, one bulk job replaces up to 5,000 single calls.
- Every call costs 1 credit, including results served from the 30-day cache.
- The SMTP mailbox and accept-all probe runs only when Cold Leads can open an SMTP connection to the recipient's mail server. Each result's reasons say whether it ran: smtp_unreachable means the mailbox was not checked.
Limits and costs
| Verifications a month | Credits | Cold Leads cost a month |
|---|---|---|
| 1,000 | 1,000 | $99: the Business plan, which includes 10,000 credits |
| 10,000 | 10,000 | $99 |
| 50,000 | 50,000 | $299: $99 plus 40 extra packs of 1,000 credits at $5 |
The API is only in the Business plan, which can also be paid yearly ($999). Finder calls draw on the same credits, 1 per call. Monthly credits are counted per calendar month (UTC) and used before purchased packs. Prices are in US dollars; tax may apply.
FAQ
Is there a Cold Leads equivalent of Hunter's Domain Search or Apollo's people search?
No. Cold Leads does not collect people data. GET /api/v1/leads searches only the contacts you already keep in your Cold Leads CRM.
Will my existing score thresholds still work?
Not reliably, because the scales differ. Decide on status and the last reason code instead: ok to send, catch_all and smtp_unknown to review, mailbox_missing, no_mx and syntax to drop, and smtp_unreachable for addresses whose mailbox was not checked.
Can I verify an Apollo export with Cold Leads?
Yes. Send the addresses as bulk jobs of up to 5,000 and poll the results, or use the Python script for CSV files on this site. Each address costs 1 credit.
Do cached results cost less?
No. A result served from the 30-day cache is marked cached: true and costs 1 credit like any other call.