An online lead generation portal is a place where you describe who you want, see how many exist, and download them. You can build the working core of one against an API in about 20 minutes.
Most pages on this topic compare directory sites and lead marketplaces. This one shows the queries. If you want a hosted screen with no code, the LeadOcean app (app.leadocean.io) has an Exports page: filter builder, column picker, price preview and CSV download. It is not a sequencer, dialer or CRM. This guide covers the API route behind it.
TL;DR
- Four steps: size the audience for free, pull a sample, enrich one person, export the list as CSV.
- It takes about 20 minutes. The test spends about 28 LeadOcean records. Counting spends none.
- The Free plan covers Steps 1 to 3: 1,000 records for life, no card. Bulk CSV export is not on Free, so Step 4 needs Solo ($97 a month) or higher.
Prerequisites
- A LeadOcean account and an API key from app.leadocean.io, stored as
$LEADOCEAN_API_KEY. curlandjq.- Optional: the hosted MCP server at
https://api.leadocean.io/mcp(OAuth sign-in, no key pasted) if you would rather run Step 1 from an AI client.
export LEADOCEAN_API_KEY="paste-your-key-here"Step 1: Size the audience before you pull anything
A portal that spends records to show a number is a bad portal. Send count=true as a query parameter with limit 1 in the body. It is free.
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": ["CA"],
"limit": 1
}' | jq '.meta'meta.total is capped at 100,000, so a wide filter reads as a floor. Filters are arrays of strings. Enum values are exact: VP, not vp, and Sales & Business Development, not Sales.
From MCP, the same sizing is the free tool leadocean_count_leads. For companies, use GET /v1/companies/count or leadocean_count_companies. Both are free.
Step 2: Pull a sample page
Pull 25 people to check the fit before you commit to a list. A search page of 25 is 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": ["CA"],
"limit": 25
}' > page1.json
jq '.meta' page1.jsonThe body is flat JSON. limit runs 1 to 100. To get the next page, pass the nextCursor value back as cursor. One search pages to 10,000 rows. When meta.depthCapped is true, narrow the filters instead of walking further.
To walk more than one page, loop on the cursor:
FILTERS='{"jobLevel":["VP"],"jobFunction":["Sales & Business Development"],"country":["CA"],"limit":25}'
BODY="$FILTERS"
for i in 1 2 3; do
curl -s -X POST "https://api.leadocean.io/v1/people/search" \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "Content-Type: application/json" \
-d "$BODY" > "page$i.json"
CURSOR=$(jq -r '.meta.nextCursor // empty' "page$i.json")
[ -z "$CURSOR" ] && break
BODY=$(echo "$FILTERS" | jq -c --arg c "$CURSOR" '. + {cursor: $c}')
doneThree pages of 25 spend 75 records. Keep the test at one page.
Step 3: Enrich one person
Search gives you the profile. Enrich adds contact detail and career history. Enrich takes exactly one of linkedin_url, email, phone or person_id. There is no name plus domain lookup, so search first and enrich by person_id.
PERSON_ID=$(jq -r '.data[0].meta.source_ids.person_id' page1.json)
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\", \"reveal_email\": true}"A person we hold nothing for is a 404 and costs nothing. For a direct phone, call GET /v2/people/phone. Check email_status before you send: bounced emails are not refunded.
Step 4: Export the list as a CSV
This is the download button of your portal. One request takes up to 50,000 rows, and the cap is 500,000 rows 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": "ca-vp-sales",
"filters": {
"jobLevel": ["VP"],
"jobFunction": ["Sales & Business Development"],
"country": ["CA"]
},
"limit": 5000,
"columns": ["profile_full_name", "current_job_title", "current_company_name",
"current_company_domain", "email_N_address", "email_N_status", "phone_N_number"]
}'Column ids come from GET /v1/exports/columns. Numbered columns are requested as email_N_address, and the CSV headers come back as email_1_address, email_2_address. Poll GET /v1/exports/{id} (free) until status is done, then download from GET /v1/exports/{id}/download. Each row is one record.
What you get
This is the free count for the Step 1 query, run with the MCP tool leadocean_count_leads on 2026-10-21. It uses the default mailable filter (verified, catch_all_valid or catch_all):
{
"total": 5574,
"totalIsExact": true,
"totalAll": 18800,
"distinctCompanies": 3873,
"credits": 0
}So 5,574 mailable VPs of sales in Canada across 3,873 employers. The same filters with no email condition match 18,800 people (totalAll). That gap is the reason to read the count label.
A person row from search looks like this. This is the response shape from the API reference, with placeholder values. Emails and phones appear only on enrich with reveal_email.
{
"success": true,
"data": [
{
"profile_data": { "profile_full_name": "Jane Doe", "profile_headline": "VP Sales at Acme" },
"contact_data": {
"has_email": true,
"has_phone": true,
"email_status": "verified",
"contact_current_experiences": [{ "...": "company name, domain, job title" }]
},
"meta": { "source_ids": { "person_id": "1234567890" } }
}
],
"meta": { "count": 25, "nextCursor": "(opaque string)" }
}Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key | Check $LEADOCEAN_API_KEY is exported and the header name is exact |
| 429 | Above the plan's rate limit (5 a second on Free, 200 on Agency) | Wait for Retry-After, then slow the loop |
| 402 | Out of records (Free is 1,000 for life) | Upgrade at /pricing; counts still work |
| Empty results | Bad enum value, such as vp for VP | Check exact strings with GET /v1/enums or leadocean_list_enum_values |
| 404 on enrich | Nobody matched the input | Costs nothing. Try another identifier |
| Export refused | Over 50,000 rows a request, 500,000 a day, or on the Free plan | Split the filter, or move to Solo or higher |
FAQ
Is a portal the same as a lead marketplace?
No. A marketplace sells fixed lists. A portal lets you set the filters, see the count, then take the rows. The API above is the second kind.
Do I need to code?
Not for a one-off list. Use the Exports page in the app. Use the API when you want the portal inside your own tool.
How many records does a test cost?
About 28 here: 25 from the sample page and up to 3 enrichments. The count and a 404 are free.
How does this fit the funnel?
The portal sits at the top: it builds the list. See outbound lead generation, the lead generation funnel and predictive lead generation for what comes next.
Build your lead portal on a free API key
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →