Pre-Black Friday: 20% off, for as long as you stayEARLYBFon Solo and AgencyEnds Nov 28Start free

How-to guide

How to Build B2B Company and Contact Data (With a People API)

Five steps from a free market count to a refreshed CSV of companies and mailable contacts. Real requests, real counts, the response shape.

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

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=...
  • curl and jq.

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.

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

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

TableKeyFields to keepChanges
Companiescompany domainname, industry, employee range, HQ country and state, fetched_atSlowly
Contactsperson idname, title, job level, company domain, email, email status, fetched_atOften

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.

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

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

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

QuestionFilterTotal
CompaniesUS HQ, 51-200 or 201-500 employees, Software Development11,577
Companies with a mailable contactthe same, plus hasMailableContacts7,793
VP and C-Team sales contacts, no email filterthe same companies, jobLevel VP or C-Team, Sales & Business Development12,139
The same contacts, mailabledefault status: verified, catch_all_valid, catch_all4,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:

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

ErrorCauseFix
401Missing or wrong x-api-key headerRe-export $LEADOCEAN_API_KEY and check the header name
429Above the plan's rate limit: 5 requests a second on Free, 200 on AgencyWait for the Retry-After value, then retry
402The 1,000 free records are spentUpgrade to Solo ($97 a month) or Agency ($497 a month)
Empty resultsA filter value is not an exact enum string, such as vp in jobLevelList the values with /v1/enums/job_level and copy one
400 export_limit_rowsThe export asks for more than 50,000 rowsSplit the filters into several exports
meta.depthCapped is trueOne search pages to 10,000 rows at mostNarrow 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?
  1. 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 →