TL;DR
- You build one script that runs weekly: count the ICP, export mailable people, drop anyone already contacted, write a CSV your sequencer can import.
- It takes about 30 minutes to build. The 40-person example spends up to 80 of your records, because a row costs 1 record, or 2 when its columns need an enrichment (email columns do). Sizing is free.
- LeadOcean supplies the people and the email status. It does not send mail. Your sequencer sends.
Guides such as Clay's outbound guide (checked October 2026) explain the idea of signal-led sequences. This page is the runnable version for a technical buyer: real queries, real output shape, no screenshots.
Prerequisites
- A LeadOcean API key from app.leadocean.io, exported as
$LEADOCEAN_API_KEY. Bulk CSV export is not on Free. Solo ($97 a month) includes it. On Free you can still run Steps 1 and 2, and replace Step 3 with a small people search. - Python 3.9 or later with
pip install requests. - A sequencer or CRM that imports a CSV. This guide never touches it, so any will do.
- A machine that stays on at the scheduled time (cron on macOS or Linux).
export LEADOCEAN_API_KEY="paste-your-key-here"
mkdir outbound-job && cd outbound-jobStep 1: Write the ICP as a filter file
Keep the ICP in one JSON file so the count, the export and the next person who reads the repo all use the same definition. The body is flat JSON and every filter is an array.
{
"jobLevel": ["VP"],
"jobFunction": ["Sales & Business Development"],
"country": ["US"],
"employeeRange": ["51-200"],
"technologyCategories": ["CRM"],
"emailStatus": ["verified", "catch_all_valid", "catch_all"]
}Save it as icp.json. Enum values are exact strings. If you are unsure of one, look it up first, which is free.
curl -s "https://api.leadocean.io/v1/enums/job_function" \
-H "x-api-key: $LEADOCEAN_API_KEY" | head -c 400Step 2: Size the audience for free
Count before you export. A count spends no records, however often you run it. Send count=true as a query parameter with limit at 1.
curl -s "https://api.leadocean.io/v1/people/search?count=true" \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
-d "$(jq '. + {limit: 1}' icp.json)" \
| jq '.meta | {total, totalIsExact, distinctCompanies, countOnly}'meta.total is capped at 100,000, so a bigger audience reads as a floor. If the number is too large for one export (50,000 rows), narrow the filters. If it is under 50, widen the title or headcount band.
Step 3: Export the people
POST /v1/exports takes your filters, a row limit and a column list. Ask only for the columns your sequencer needs. One export runs per account at a time, and each export is capped at 50,000 rows and 500,000 rows a day.
curl -s https://api.leadocean.io/v1/exports \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
-d "$(jq -n --slurpfile f icp.json '{
name: "weekly-vp-sales",
filters: $f[0],
limit: 500,
caps: {emails: 1},
columns: ["person_id","profile_first_name","profile_last_name",
"current_job_title","current_company_name","current_company_domain",
"email_N_address","email_N_status"]
}')" | jq '.data | {id, status, requested, perRow, remaining}'Numbered columns are requested as email_N_address and email_N_status, with caps.emails setting how many. The CSV headers come back as email_1_address and email_1_status. The call is accepted or refused on the spot. It reserves records for up to limit rows, bills each row written and gives the rest back.
Step 4: Write the weekly job
Save this as weekly_outbound.py. It creates the export, polls until it finishes, downloads the CSV and drops every person_id you have already sent. sent.txt is your memory between runs.
import csv, json, os, time, requests
BASE = "https://api.leadocean.io"
H = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "content-type": "application/json"}
BASE_COLS = ["person_id", "profile_first_name", "profile_last_name",
"current_job_title", "current_company_name", "current_company_domain"]
REQUEST_COLS = BASE_COLS + ["email_N_address", "email_N_status"]
CSV_COLS = BASE_COLS + ["email_1_address", "email_1_status"]
filters = json.load(open("icp.json"))
sent = set(open("sent.txt").read().split()) if os.path.exists("sent.txt") else set()
r = requests.post(f"{BASE}/v1/exports", headers=H, json={
"name": time.strftime("weekly-%Y-%m-%d"), "filters": filters,
"limit": 500, "caps": {"emails": 1}, "columns": REQUEST_COLS})
r.raise_for_status()
exp = r.json()["data"]
while exp["status"] in ("queued", "running"):
time.sleep(exp.get("pollAfter") or 5)
exp = requests.get(f"{BASE}/v1/exports/{exp['id']}", headers=H).json()["data"]
if exp["status"] != "done":
raise SystemExit(f"export {exp['status']}: {exp.get('error')}")
body = requests.get(f"{BASE}/v1/exports/{exp['id']}/download", headers=H).text
rows = [x for x in csv.DictReader(body.splitlines()) if x["person_id"] not in sent]
out = time.strftime("import-%Y-%m-%d.csv")
with open(out, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=CSV_COLS, extrasaction="ignore")
w.writeheader()
w.writerows(rows)
with open("sent.txt", "a") as f:
f.writelines(x["person_id"] + "\n" for x in rows)
print(f"{len(rows)} new people written to {out}")Run it once by hand and read the CSV before you schedule anything. Add sent.txt entries from your CRM first if you have existing contacts.
Step 5: Gate the file before it ships
An unattended job needs a gate. Add a check that stops the hand-off when the file looks wrong, so a bad week never reaches a sequencer. Save this as gate.py and run it after the job.
import csv, glob, sys
path = sorted(glob.glob("import-*.csv"))[-1]
rows = list(csv.DictReader(open(path)))
ok = {"verified", "catch_all_valid", "catch_all"}
bad = [r for r in rows if r["email_1_status"] not in ok or not r["email_1_address"]]
domains = {r["current_company_domain"] for r in rows}
print(f"{path}: {len(rows)} rows, {len(domains)} companies, {len(bad)} unmailable")
if not rows or bad:
sys.exit(1)A non-zero exit means: do not import, and remove that week's IDs from sent.txt before the next run. A zero-row week usually means the audience is spent, which is a signal to change the slice, not a failure of the job. Keep a cap per company too. Ten people from one company in one week is a list problem, not a volume win.
Step 6: Schedule it and hand off
Cron runs the script every Monday at 06:00. Then import import-YYYY-MM-DD.csv into your sequencer. Most sequencers can watch a folder or accept a CSV upload. Check your own tool's docs for that step.
crontab -e
0 6 * * 1 cd $HOME/outbound-job && (python3 weekly_outbound.py && python3 gate.py) >> job.log 2>&1Cron does not load your shell profile, so set LEADOCEAN_API_KEY at the top of the crontab or in a secrets file. Never commit it to the repository. Read email_1_status before you send. Bounced emails are not refunded.
What you get
Step 2 returns a real number. On 2026-10-21 the free MCP count (leadocean_count_leads, default email filter) for this ICP returned the result below. Filters: VP, Sales & Business Development, US, 51-200 employees, CRM detected on the site.
{
"total": 40,
"totalIsExact": true,
"totalAll": 135,
"distinctCompanies": 30,
"credits": 0,
"defaults": "Counted only addresses worth sending to (emailStatus: verified or catch_all_valid or catch_all)"
}That is 40 mailable people across 30 companies. The other 95 of the 135 have no verified or catch-all address.
A finished export, as the response shape from the API reference with placeholder values:
{
"success": true,
"data": {
"id": "exp_123",
"name": "weekly-vp-sales",
"status": "done",
"requested": 500,
"estimatedTotal": 40,
"rows": 40,
"billed": 40,
"released": 460,
"pollAfter": null,
"downloadUrl": "https://api.leadocean.io/v1/exports/exp_123/download?t=..."
}
}The CSV has one row per person. One placeholder row:
person_id,profile_first_name,profile_last_name,current_job_title,current_company_name,current_company_domain,email_1_address,email_1_status
p_123,Jane,Doe,VP Sales,Acme,acme.com,jane.doe@acme.com,verifiedTroubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key | Copy the key again from app.leadocean.io and re-export $LEADOCEAN_API_KEY. |
429 on /v1/exports | The 500,000-row daily export ceiling (or the period ceiling) is reached | Wait until resetsAt in the response. Smaller weekly slices stay far under it. |
| 429 on other calls | Above the plan's rate limit (5 requests a second on Free, 200 on Agency) | Wait the Retry-After seconds. The job makes few calls, so this is rare. |
| 402 | Fewer records left than the export needs | Lower limit, or check GET /v1/account. Free records do not reset. |
400 export_limit_rows | limit is above 50,000 | Set limit to 50,000 or less and split the audience by country or headcount band. |
| Empty file or 400 on filters | An enum value that does not match, such as "VP Sales" in jobLevel | Resolve the exact string with GET /v1/enums/<name> and use it. |
FAQ
Does LeadOcean send the emails?
No. It supplies people, work emails and a deliverability label. Sending and sequencing belong to your sequencer.
Why track person_id instead of email?
An address can change between runs. person_id is the stable key, and an export dedupes rows by it.
How do I avoid pulling the same people every week?
An export always starts at the top of its filter set, so the same people come back. Dedupe against sent.txt as in Step 4. When most rows are repeats, change the slice (a new country or headcount band) instead of raising limit.
Can I trigger this from Claude Code or n8n instead of cron?
Yes. The job is plain HTTP, so any scheduler that can run a script or call POST /v1/exports works. LeadOcean also has a hosted MCP server with 13 tools, and the count tool is free. See outbound lead generation and warm outbound for the terms, outbound sales automation for the wider stack, and pricing for plans.
More recipes live in the blog hub.
Start your weekly outbound export with a free API key
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →