Build B2B company and contact data in five steps: size the market for free, pull the companies, attach mailable contacts, enrich the gaps, then export and refresh. Each step below is one request against LeadOcean. The sample run spends 11 records.
Examples use placeholders (Acme, acme.com, Jane Doe). Swap in your own filters.
TL;DR
- Keep company records and contact records separate, and join them on the company domain. Companies change slowly. People change jobs.
- Size every list with a free count before you pull rows. A wrong filter then costs nothing to find.
- Takes about 30 minutes and spends 11 of the 1,000 free records. Counts, enum lookups and the export price preview are free.
Most guides to B2B data building describe a system: required fields, merge rules, refresh cycles. Crustdata's guide is a fair example. It is correct and gives you nothing to run. This one is shorter and gives you the queries.
The order is deliberate. Free calls come first (Step 1), then a small paid sample (Steps 2 to 4), then the bulk pull (Step 5).
Prerequisites
- A LeadOcean account and a key from app.leadocean.io. The Free plan works: no card, 1,000 records for life.
- The key in your shell:
export LEADOCEAN_API_KEY=... curlandjq.
All requests go to https://api.leadocean.io with the header x-api-key.
Step 1: Size the company list for free
Start with the companies you want, not the people. GET /v1/companies/count takes the company-search filters and returns an exact total with breakdowns. It costs nothing and returns no rows.
curl -s -G "https://api.leadocean.io/v1/companies/count" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
--data-urlencode "hqCountry=US" \
--data-urlencode "employeeRange=51-200,201-500" \
--data-urlencode "industry=Software Development" \
| jq '{total, totalIsExact}'Enum values are exact strings. 51-200 is an employeeRange. Software Development is an industry. Fetch the allowed values from /v1/enums/industry first, or with the leadocean_list_enum_values MCP tool. Both are free.
Add hasMailableContacts=true to count only companies with at least one person whose best email is verified, catch_all_valid or catch_all. That is the number your outbound list can use.
Step 2: Pull the company records
Now fetch the rows. Company search takes a flat JSON body. Filters are arrays of strings, limit runs from 1 to 100, and each company returned is one record.
curl -s -X POST "https://api.leadocean.io/v1/companies/search" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"hqCountry": ["US"],
"employeeRange": ["51-200", "201-500"],
"industry": ["Software Development"],
"hasMailableContacts": true,
"limit": 5
}' | jq '{next: .meta.nextCursor, domains: [.data[].company_data.company_domain]}'Page with meta.nextCursor. Pass the string back unchanged. One search pages to 10,000 rows. Past that, meta.depthCapped is true and no cursor comes back, so split a large market into narrower searches (one per HQ state works well).
Store the company domain as your key. Every later step joins on it. Save the filter body next to the rows, so next month's refresh runs the same query and you can diff the two pulls.
The data model in two tables
Two tables are enough. Do not merge them into one wide sheet.
| Table | Key | Fields to keep | Changes |
|---|---|---|---|
| Companies | company domain | name, industry, employee range, HQ country and state, fetched_at | Slowly |
| Contacts | person id | name, title, job level, company domain, email, email status, fetched_at | Often |
A contact row points at a company by domain. When someone changes jobs, you update one contact row and the company row stays put. When a company changes headcount band, no contact row moves.
Two rules keep the dataset clean. Never overwrite a field without keeping the old value's date. Never join on company name, because names collide and domains do not.
Keep email_status on every contact, not just the address. It is the field that tells you whether to send. Keep fetched_at on both tables, because it tells you what to refresh first.
If you work in an AI client instead of curl, the hosted MCP server at https://api.leadocean.io/mcp exposes the same calls as tools, including leadocean_count_companies, leadocean_search_companies and leadocean_search_leads. It signs in with OAuth, so no key is pasted.
Step 3: Attach mailable contacts
Contacts live in their own table. Search people at the domains from Step 2, and gate on the email status in the query so you never pay for a row you cannot send to.
curl -s -X POST "https://api.leadocean.io/v1/people/search" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"domain": ["acme.com", "example.io"],
"jobLevel": ["VP", "C-Team"],
"jobFunction": ["Sales & Business Development"],
"emailStatus": ["verified"],
"emailType": ["work"],
"limit": 5
}'jobLevel takes VP and C-Team. seniority takes lowercase values such as vp and c_suite. Mixing the two is the usual cause of an empty result.
Each row carries a person id at meta.source_ids.person_id. Keep it with the domain. You need it in Step 4.
For a full roster of one company, GET /v1/companies/{domain}/people returns thin records, one record per person, with no contact points.
Step 4: Enrich the gaps
Search rows are thin. Enrich only the people you will contact. Enrichment takes exactly one of linkedin_url, email, phone or person_id. There is no name plus domain lookup, which is why Step 3 comes first.
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":"1234567890","reveal_email":true}' \
| jq '.data.contact_data | {email_status, email_type, contact_emails}'The email is returned only with reveal_email set to true. A person we hold nothing for is a 404 and costs nothing. Since October 2026 the response also carries resume_data: past roles, education, certifications and awards. Past employers carry no company id yet, so join them by name, not by key.
Enrich on demand, not in bulk. A cold list of 5,000 people rarely needs 5,000 enrichments. Enrich when a contact enters a sequence or a routing rule, and read email_status again at that moment.
Step 5: Export, then refresh monthly
Pull the full list as a CSV with POST /v1/exports. Bulk CSV export is not on the Free plan. Solo includes it, and Agency exports up to 50,000 rows a file. The API allows 50,000 rows a request and 500,000 a day.
curl -s -X POST "https://api.leadocean.io/v1/exports" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "us-saas-vp-sales",
"filters": {
"hqCountry": ["US"],
"employeeRange": ["51-200", "201-500"],
"industry": ["Software Development"],
"jobLevel": ["VP", "C-Team"],
"jobFunction": ["Sales & Business Development"],
"emailStatus": ["verified"]
},
"limit": 5000,
"preset": "full"
}'The call returns an export id. Poll GET /v1/exports/{id}, then download from GET /v1/exports/{id}/download. If you prefer clicks, the Exports page in the app has the same filter builder, a column picker and the record price before you start.
The dataset refreshes monthly, and each record carries its own fetched_at date. Keep the saved filter, re-run the Step 1 count each month, and re-pull what changed. Do not patch rows by hand. Re-pull the slice.
Gate the refresh on cost. A count is free, so compare this month's company total with last month's before you spend anything. If the total and the mailable total barely moved, refresh only the contacts at companies you are actively working.
What you get
Counts below were run on 2026-10-21 with the free leadocean_count_companies and leadocean_count_leads tools. No rows were returned.
| Question | Filter | Total |
|---|---|---|
| Companies | US HQ, 51-200 or 201-500 employees, Software Development | 11,577 |
| Companies with a mailable contact | the same, plus hasMailableContacts | 7,793 |
| VP and C-Team sales contacts, no email filter | the same companies, jobLevel VP or C-Team, Sales & Business Development | 12,139 |
| The same contacts, mailable | default status: verified, catch_all_valid, catch_all | 4,817 across 2,291 companies |
The 12,139 people shrink to 4,817 once you ask for an address worth sending to. That gap is the data-building decision. Know which number your list is built on.
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.doe@acme.com", "type": "work", "status": "verified" }
]
},
"meta": { "source_ids": { "person_id": "1234567890" } }
}Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Re-export $LEADOCEAN_API_KEY and check the header name |
| 429 | Above the plan's rate limit: 5 requests a second on Free, 200 on Agency | Wait for the Retry-After value, then retry |
| 402 | The 1,000 free records are spent | Upgrade to Solo ($97 a month) or Agency ($497 a month) |
| Empty results | A filter value is not an exact enum string, such as vp in jobLevel | List the values with /v1/enums/job_level and copy one |
400 export_limit_rows | The export asks for more than 50,000 rows | Split the filters into several exports |
meta.depthCapped is true | One search pages to 10,000 rows at most | Narrow the filters and run several searches |
FAQ
What is B2B data building?
It is assembling company and contact records into one dataset you can query, join and refresh. The work is choosing the filters, separating the two record types and keeping both current.
How many records does this guide spend?
- Five company rows, five people rows and one enrichment. The count, the enum lookups and a search that returns nothing are free.
Should company and contact data live in separate tables?
Yes. A company changes slowly and a contact changes jobs. Join on the company domain, and a role change touches one row, not two.
How fresh is the data?
The dataset is refreshed monthly, and each record carries its own fetched_at date. See how B2B data is collected for where the records come from.
Which plan do I need?
Free covers the 11-record run. Solo ($97 a month) adds bulk CSV export for one person. Agency ($497 a month) is for teams and volume. See pricing, the contact database comparison and the company data API guide.
Build your first company and contact list on 1,000 free records
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →