A B2B contact list is a set of people at companies you want to sell to, with a job title and a deliverable work email for each. This guide builds one with filters, not a scraped spreadsheet. You size it first, because sizing costs nothing.
TL;DR
- Define the list as filters (title level, function, country, company size, industry), then count it before you pull a single row.
- Pull a 25-row sample, read
email_status, then export the full list as CSV. - Takes about 20 minutes. Spends about 26 records on the Free plan (1,000 records for life, no card). Counts spend none.
Prerequisites
- A LeadOcean API key from app.leadocean.io, stored as
$LEADOCEAN_API_KEY. The Free plan includes search and enrich scopes. curlandjq.- A written ICP: who you sell to, in one line. Ours below is VP-level sales leaders at 51-200 person US software companies.
Step 1: Write the ICP as filters
Filters are closed lists, so a guessed value returns nothing. Check exact strings first. The enum calls are free.
curl -s "https://api.leadocean.io/v1/enums/job_level" -H "x-api-key: $LEADOCEAN_API_KEY"
curl -s "https://api.leadocean.io/v1/enums/employee_range" -H "x-api-key: $LEADOCEAN_API_KEY"The ICP becomes this flat JSON body. Filters are arrays of strings.
{
"jobLevel": ["VP"],
"jobFunction": ["Sales & Business Development"],
"country": ["US"],
"employeeRange": ["51-200"],
"industry": ["Software Development"]
}The MCP equivalent is leadocean_list_enum_values. There are 50 people filters and 34 company filters in total.
Step 2: Count the list for free
Send count=true as a query parameter and limit 1 in the body. It returns the total and no rows.
curl -s -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":["US"],"employeeRange":["51-200"],"industry":["Software Development"],"limit":1}'The same count through MCP is leadocean_count_leads. Count the companies with GET /v1/companies/count or leadocean_count_companies. A count that is too small means loosen one filter. A count in the millions means add one.
Step 3: Pull a 25-row sample
Pull a small page before you commit to the full list. This spends 25 records.
curl -s -X POST "https://api.leadocean.io/v1/people/search" \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "Content-Type: application/json" \
-d '{"jobLevel":["VP"],"jobFunction":["Sales & Business Development"],"country":["US"],"employeeRange":["51-200"],"industry":["Software Development"],"limit":25}' \
| jq '.meta, (.data | length)'limit takes 1 to 100. To page, send the nextCursor from meta as cursor in the next request. One search pages to 10,000 rows. If meta.depthCapped is true, narrow the filters instead of walking further.
Step 4: Filter on email status
Email status decides whether a list is sendable. LeadOcean has 13 values. The count tool treats verified, catch_all_valid and catch_all as mailable by default.
{
"jobLevel": ["VP"],
"country": ["US"],
"emailStatus": ["verified"],
"emailType": ["work"]
}verified is the strict cut. Adding it shrinks the list, and that is the point. There is no refund for bounced emails, so read email_status before you send.
Step 5: Enrich the rows you keep
Search gives you the row. Enrich gives you the address. Send exactly one of linkedin_url, email, phone or person_id, with reveal_email set to true.
curl -s -X POST "https://api.leadocean.io/v1/people/enrich" \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "Content-Type: application/json" \
-d '{"person_id":"PERSON_ID_FROM_SEARCH","reveal_email":true}'An enrichment that finds nothing is a 404 and costs nothing. reveal_email also returns phones we hold. For phones alone, use GET /v2/people/phone.
Step 6: Export the full list
For more than a few hundred rows, export instead of paging. The body takes the same filters plus limit.
curl -s -X POST "https://api.leadocean.io/v1/exports" \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "Content-Type: application/json" \
-d '{"name":"vp-sales-us-software","limit":5000,"preset":"full","filters":{"jobLevel":["VP"],"jobFunction":["Sales & Business Development"],"country":["US"],"employeeRange":["51-200"],"industry":["Software Development"],"emailStatus":["verified"]}}'It returns 202 and runs async. Poll GET /v1/exports/{id}, then fetch GET /v1/exports/{id}/download. A request takes up to 50,000 rows, and an account up to 500,000 a day. Bulk CSV export is not on Free, so it needs Solo ($97 a month) or higher. The same export runs from the Exports page in app.leadocean.io: filter builder, column picker, price preview, CSV download.
Step 7: Keep the list clean
A list is a query, not a file. Save the filter JSON in your repo and rebuild when you need a send. Three habits keep it healthy.
- Exclude what you already contacted. Use
excludeCompanyNameandexcludeTitlefor competitors, agencies and interns. - Re-run the count each month. The dataset is refreshed monthly, so the total moves.
- Keep one list per ICP. A VP Sales list and a RevOps list need different messages, so they should be different exports.
{
"jobLevel": ["VP"],
"country": ["US"],
"excludeTitle": ["assistant", "intern"],
"excludeCompanyName": ["Acme"]
}What each step costs
| Step | Call | Records |
|---|---|---|
| 1. Enums | GET /v1/enums | 0 |
| 2. Count | count=true, limit=1 | 0 |
| 3. Sample | search, limit 25 | 25 |
| 4. Status filter | search, emailStatus | 1 per row returned |
| 5. Enrich | POST /v1/people/enrich | 1 per person found |
| 6. Export | POST /v1/exports | 1 per row written |
One record is one person or one company you receive. A search page of 25 is 25 records. On Free, 1,000 records covers the 25-row sample, a few dozen enrichments and more search pages. The CSV export needs Solo or higher.
What you get
A live count for the Step 1 ICP, run on 2026-10-21 with the free MCP tool leadocean_count_leads:
{
"total": 1649,
"totalIsExact": true,
"totalAll": 4141,
"distinctCompanies": 1125
}That is 1,649 people with a mailable address (verified, catch_all_valid or catch_all), out of 4,141 matching people with no email condition, across 1,125 companies. The company side, from leadocean_count_companies on 2026-10-21: 9,261 US-headquartered companies in Software Development with 51-200 employees.
A person record has this shape (response shape from the API reference, placeholder values):
{
"profile_data": { "profile_full_name": "Jane Doe" },
"contact_data": {
"has_email": true,
"email_status": "verified",
"email_type": "work",
"contact_emails": [{ "email": "jane@acme.com", "status": "verified" }]
},
"meta": {}
}contact_emails appears only when reveal_email is true.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key | Copy the key from app.leadocean.io into $LEADOCEAN_API_KEY |
| 429 | Above the plan's rate limit (5 a second on Free, 200 on Agency), or an export ceiling | Honor Retry-After. For exports, wait for the daily reset |
| 402 | Out of records (Free is 1,000 for life) | Check GET /v1/usage, then move to a paid plan |
| Empty results | A filter value that is not an exact enum string | Look it up with /v1/enums or leadocean_list_enum_values |
| 400 on export | Over 50,000 rows, or a filter the export cannot apply | Lower limit, or split the list into two exports |
| Total looks small | The default count is mailable people only | Compare with totalAll, or pass emailStatus yourself |
FAQ
How big should a B2B contact list be?
Start with the smallest list you can write to this week. A few hundred well-matched rows beats 50,000 loose ones. Count first and tighten the filters until the number fits.
Is a purchased list the same thing?
No. A static file starts decaying the day you buy it. The dataset here is refreshed monthly and each record carries its own fetched_at date, so rebuild the list from filters when you need it.
What does the list cost on the free plan?
Counts, enum lookups and empty enrichments are free. The walkthrough above spends about 26 records of your 1,000. Bulk CSV export starts on Solo at $97 a month. See pricing.
Can I add phone numbers?
Yes. Use GET /v2/people/phone per person, or the full export preset, which carries two phone columns by default.
Where does warm-up fit?
After the list, before the first send. A clean list protects a new domain, and a new domain still needs a ramp. See inbox warming, inbox warm-up and Gmail warmup. More guides live in the blog.
Count your ICP before you buy a single row
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →