How-to guide

How to Build a List, Verify, Send Stack (B2B Outbound Data Stack)

Pull a filtered list from the LeadOcean API, verify only the risky emails, and load a sender. A 400-row pilot runs on the free plan.

Get your free API key →Free to start. No credit card. 1,000 records to spend whenever you like.

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_all rows. Rows LeadOcean already marks verified or catch_all_valid skip 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_KEY with the scopes leads:write, lead_lists:write and recipients:write, plus an empty static lead list and a draft campaign.
  • curl and Python 3 with requests.

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.

bash
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.

bash
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.

bash
curl -H "x-api-key: $LEADOCEAN_API_KEY" -o leads.csv \
  https://api.leadocean.io/v1/exports/EXPORT_ID/download
python
import 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).

python
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.

python
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.

text
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 0

Catch-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.

json
{
  "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

ErrorCauseFix
401 "Missing or malformed x-api-key"The header is missing or wrong.Send x-api-key: $LEADOCEAN_API_KEY.
402 on export createFewer records are left than the export reserves.Lower limit. On Free the 1,000 are one-off.
403 on export createEmail columns were asked for without the enrich scope.Use a key with that scope, or drop email_N_address.
429More 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 createlimit is over 50,000, or a filter cannot be applied.Split into several exports with narrower filters.
409 on downloadThe export is still running.Poll until status is done.
Zero rows backA 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 →