A B2B outbound data stack has three jobs: build the list, check the emails, send the messages. This guide wires them together with one export, one verification pass and one import. You can run the whole pilot on LeadOcean's free plan.
The example audience is VPs of sales in Canada. Swap the filters for yours.
TL;DR
- Time: about 40 minutes for a 400-row pilot, most of it waiting on the export and the verifier.
- Records: 400 to 800 LeadOcean records. Sizing the list first is free, and the free plan has 1,000.
- Rule: spend verification only on
catch_allrows. Rows LeadOcean already marksverifiedorcatch_all_validskip it.
Prerequisites
- A LeadOcean key in
$LEADOCEAN_API_KEY. Sign up at app.leadocean.io, no card needed. - A VerifyHQ key in
$VERIFYHQ_API_KEY. VerifyHQ says new accounts get free verifications (source: VerifyHQ, September 2026). - A ReachHQ key in
$REACHHQ_API_KEYwith the scopesleads:write,lead_lists:writeandrecipients:write, plus an empty static lead list and a draft campaign. curland Python 3 withrequests.
Any sender with an import API works in step 5. ReachHQ is the example because we have a tested recipe for it.
Step 1: Size the list before you spend anything
Count first. A search with count=true and limit=1 is free.
curl -X POST https://api.leadocean.io/v1/people/search?count=true \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"jobLevel":["VP"],"jobFunction":["Sales & Business Development"],"country":["CA"],"emailStatus":["verified","catch_all_valid","catch_all"],"emailType":["work"],"limit":1}'Read meta.total. It is capped at 100,000, which is far above a pilot. Filters join with AND, and several values inside one filter join with OR.
Step 2: Export the pilot list
Export 400 rows, not the whole audience. The export is asynchronous and reserves records up front.
curl -X POST https://api.leadocean.io/v1/exports \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"vp-sales-ca pilot","filters":{"jobLevel":["VP"],"jobFunction":["Sales & Business Development"],"country":["CA"],"emailStatus":["verified","catch_all_valid","catch_all"],"emailType":["work"]},"limit":400,"columns":["person_id","profile_first_name","profile_last_name","current_job_title","current_company_name","current_company_domain","email_status","email_N_address"],"caps":{"emails":1}}'The answer is a 202 with an export id. Poll GET /v1/exports/{id} until status is done, waiting pollAfter seconds between reads. One export runs per account at a time.
Email addresses are contact data, so the key needs the enrich scope. One export takes up to 50,000 rows, and an account takes 500,000 rows a day.
Step 3: Download and split by email status
Download the CSV, then split it on email_status. LeadOcean has already tested some addresses, so you only pay to re-check the rest.
curl -H "x-api-key: $LEADOCEAN_API_KEY" -o leads.csv \
https://api.leadocean.io/v1/exports/EXPORT_ID/downloadimport csv, re
keep, check = [], []
with open("leads.csv", newline="") as f:
reader = csv.DictReader(f)
fields = reader.fieldnames
for row in reader:
(check if row["email_status"] == "catch_all" else keep).append(row)
for name, rows in (("keep.csv", keep), ("to_verify.csv", check)):
with open(name, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
w.writerows(rows)
print(len(keep), "keep,", len(check), "to verify")A catch-all domain accepts mail for any address, so a plain check cannot confirm the mailbox. That is why catch_all is the only status worth paying to re-check.
Step 4: Verify the catch-all rows
Send to_verify.csv to VerifyHQ one address at a time, or upload it as a bulk CSV in the app. VerifyHQ says it takes files up to 500K rows and resolves catch-all domains to mailbox-level validity (source: VerifyHQ, September 2026).
import csv, os, re, requests
URL = "https://api.verifyhq.io/api/public/verify"
HEAD = {"Authorization": f"Bearer {os.environ['VERIFYHQ_API_KEY']}",
"Content-Type": "application/json"}
THRESHOLD = 80 # VerifyHQ's own page says send above 80, review 50 to 80
rows = list(csv.DictReader(open("to_verify.csv", newline="")))
addr = next(k for k in rows[0] if re.fullmatch(r"email_\d+_address", k))
good = []
for r in rows:
res = requests.post(URL, headers=HEAD, json={"email": r[addr]}, timeout=30).json()
r["vhq_score"] = res.get("score")
if (res.get("score") or 0) >= THRESHOLD and not res.get("do_not_mail"):
good.append(r)
with open("verified_from_catch_all.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
w.writeheader(); w.writerows(good)
print(len(good), "of", len(rows), "passed")VerifyHQ's site says every email gets a tier, a score and a reason (VerifyHQ, October 2026). It does not publish the response field names in public. Print one response from your own key before you run the loop, then rename score and do_not_mail in the script to match what comes back. Keep the score column so you can move the cutoff later without a second check.
VerifyHQ lists a Lite plan at $9.99 a month with 10K verification credits (source: VerifyHQ, October 2026). It publishes no API rate limit and does not say how many credits one address uses, so the loop sends one request at a time. Check your credit balance in the VerifyHQ dashboard after the first batch to see what each address cost.
Step 5: Load the sender
Combine keep.csv and verified_from_catch_all.csv, then import into a static ReachHQ list. Run dryRun first and read the preview.
import csv, os, re, requests
API = "https://api.reachhq.io/api/v1/leads/import"
HEAD = {"Authorization": f"Bearer {os.environ['REACHHQ_API_KEY']}"}
rows = []
for name in ("keep.csv", "verified_from_catch_all.csv"):
for r in csv.DictReader(open(name, newline="")):
a = next(k for k in r if re.fullmatch(r"email_\d+_address", k))
rows.append({"email": r[a], "firstName": r["profile_first_name"],
"lastName": r["profile_last_name"], "company": r["current_company_name"],
"jobTitle": r["current_job_title"], "domain": r["current_company_domain"]})
def send(action, **extra):
body = {"action": action, "source": "CSV", "fileName": "pilot.csv", "rows": rows, **extra}
return requests.post(API, headers=HEAD, json=body).json()
PREVIEW = True # read the dry-run output, then set this to False to commit
if PREVIEW:
print(send("dryRun"))
else:
print(send("commit", listId=os.environ["REACHHQ_LIST_ID"]))ReachHQ's API reference lists fieldMapping as a free-form object and publishes no row limit per request (ReachHQ API reference, October 2026). The script sends ReachHQ's own field names in each row and leaves fieldMapping out. Read the dry-run preview to see how rows are parsed, and split a long list into batches if a request is refused. After the commit, attach the list with POST /campaigns/{id}/recipients and {"action":"addFromList","leadListId":"LIST_ID"} (source: ReachHQ API spec, September 2026).
Leave the campaign in draft. Launching needs a separate scope, so review the copy before anything sends.
What you get
Step 1 is a free count. We ran the same filters through LeadOcean's MCP leadocean_count_leads tool on 2026-09-30, with a work email required.
verified + catch_all_valid total 1446 totalIsExact true credits 0
catch_all total 2718 totalIsExact true credits 0
all three statuses total 4164 totalIsExact true credits 0Catch-all rows are the larger share of this audience, so the verify step earns its place here. Your mix will differ.
The export create call answers with the shape below. This is the response shape from the API reference (openapi.json), with placeholder values.
{
"success": true,
"data": {
"id": "EXPORT_ID",
"name": "vp-sales-ca pilot",
"status": "queued",
"requested": 400,
"estimatedTotal": 4164,
"perRow": 1,
"remaining": 600,
"pollAfter": 5
}
}The fields are requested (records reserved), perRow (1, or 2 with an enrichment) and remaining (records left after the reservation). Read perRow before you scale up.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 "Missing or malformed x-api-key" | The header is missing or wrong. | Send x-api-key: $LEADOCEAN_API_KEY. |
| 402 on export create | Fewer records are left than the export reserves. | Lower limit. On Free the 1,000 are one-off. |
| 403 on export create | Email columns were asked for without the enrich scope. | Use a key with that scope, or drop email_N_address. |
| 429 | More than 100 requests a second, or a daily or period export ceiling. | Honour Retry-After. Exports allow 500,000 rows a day. |
| 400 on export create | limit is over 50,000, or a filter cannot be applied. | Split into several exports with narrower filters. |
| 409 on download | The export is still running. | Poll until status is done. |
| Zero rows back | A filter value is outside the catalogue, such as Canada for CA. | Read the valid values from GET /v1/enums. |
FAQ
Why verify only catch_all rows?
LeadOcean has already tested verified and catch_all_valid addresses. Re-checking them adds cost and no new information. Spend the verifier on the rows where the answer is still open.
Can I use a different verifier or sender?
Yes. Steps 1 to 3 are LeadOcean only. Steps 4 and 5 are plain HTTP, so any verifier or sender with an API slots in. See the integrations hub for other tools.
How many records does a full run cost?
One export row costs one record, or two when the create response shows perRow of 2. Pro at $499 a month covers searches, enrichments and lookups without a per-record price. See pricing.
Can an AI agent run this?
Steps 1 and 2 can run from an agent over LeadOcean's MCP server at https://api.leadocean.io/mcp, which has 12 tools. count_leads is free and export_leads costs one record per row.
Where do I go next?
Read how to build a prospect list by tech stack, how often to verify your email list, and how to verify a cold email list in bulk. The blog hub lists the rest.
Build your first list, verify, send pilot with a free LeadOcean key
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →