This stack splits the job in three. LeadOcean builds and verifies the list, HeyReach sends the LinkedIn sequence, and Close holds the leads, calls and deals. Each tool does one step, and you pay for three small things instead of one suite.
Splitting them lets you swap a part. If HeyReach stops fitting, the list in LeadOcean and the leads in Close stay where they are. LeadOcean holds the people and companies (693M people, 67M companies), Close holds what your team learned, and HeyReach holds only the sequence state.
The trade is glue code. You write two push scripts and one webhook handler, and you keep them working. If you would rather not, a workflow tool covers it, as in the n8n and Attio stack.
TL;DR
- Export a filtered people list from LeadOcean as CSV, push each person to HeyReach by LinkedIn URL and to Close as a lead with the verified email.
- It takes about 45 minutes the first time. A 100-person test on Free spends about 200 records (search plus enrich). A 10,000-row export spends 10,000 records on Solo.
- One sender, one rep and 10,000 contacts costs $225 a month at list price ($195 billed yearly for HeyReach and Close). LeadOcean is the only usage-sized part.
Prerequisites
- A LeadOcean key from app.leadocean.io/register, stored as
$LEADOCEAN_API_KEY. Free gives 1,000 records for life and 5 requests a second. Bulk CSV export is not on Free, so a 10,000-row run needs Solo ($97 a month) or Agency ($497 a month). - A HeyReach account with at least one connected LinkedIn sender and one campaign. Store the API key as
$HEYREACH_API_KEY. - A Close account and an API key from Settings, Developer, API Keys (Close docs, September 2026). Store it as
$CLOSE_API_KEY. - Python 3 with
requests. Nothing else.
Step 1: Size the list for free
Count before you spend. A count costs no records, so run it until the number looks right.
curl -s "https://api.leadocean.io/v1/people/search?count=true&limit=1" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "content-type: application/json" \
-d '{
"jobLevel": ["VP"],
"jobFunction": ["Sales & Business Development"],
"country": ["US"],
"employeeRange": ["51-200"]
}'Enum values are exact strings. Resolve any you are unsure of with GET /v1/enums/{name} or the free leadocean_list_enum_values MCP tool. meta.total is capped at 100,000, which is fine for sizing.
Step 2: Export the list with verified emails
Use POST /v1/exports for 10,000 rows. It writes a CSV, takes up to 50,000 rows a request and 500,000 a day, and bills one record per row whatever the columns. Search alone returns email flags, not addresses, so the export is the cheaper way to get the address.
curl -s https://api.leadocean.io/v1/exports \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "content-type: application/json" \
-d '{
"name": "us-sales-vps-51-200",
"limit": 10000,
"columns": ["person_id", "profile_first_name", "profile_last_name",
"current_job_title", "current_company_name", "profile_url",
"email_N_address", "email_N_status"],
"caps": {"emails": 1},
"filters": {
"jobLevel": ["VP"],
"jobFunction": ["Sales & Business Development"],
"country": ["US"],
"employeeRange": ["51-200"],
"emailStatus": ["verified"]
}
}'The call returns an export id and runs async. Poll GET /v1/exports/{id} until status is done, then call GET /v1/exports/{id}/download for the file and save it as export.csv. The column ids come from GET /v1/exports/columns. email_N_address with an email cap of 1 comes back as email_1_address.
Verified-only is the "verify" step. Read email_1_status in the CSV before you send. LeadOcean gives no refund for bounced emails.
Step 3: Push the list to HeyReach
HeyReach takes up to 100 leads per request on AddLeadsToCampaignV2, so 10,000 contacts is 100 requests (HeyReach docs, September 2026). Auth is an X-API-KEY header. Each lead is paired with a linkedInAccountId, the sender that will contact them.
import csv, os, time, requests
HEYREACH = "https://api.heyreach.io/api/public"
CAMPAIGN_ID = 235 # your campaign id
SENDER_IDS = [123] # your connected LinkedIn account ids
def lead(r):
return {
"firstName": r["profile_first_name"], "lastName": r["profile_last_name"],
"companyName": r["current_company_name"], "position": r["current_job_title"],
"emailAddress": r["email_1_address"], "profileUrl": r["profile_url"],
"location": "", "summary": "", "about": "",
"customUserFields": [{"name": "leadocean_id", "value": r["person_id"]}],
}
rows = [r for r in csv.DictReader(open("export.csv")) if r.get("profile_url")]
for i in range(0, len(rows), 100):
chunk = rows[i:i + 100]
pairs = [{"linkedInAccountId": SENDER_IDS[(i + n) % len(SENDER_IDS)], "lead": lead(r)}
for n, r in enumerate(chunk)]
resp = requests.post(f"{HEYREACH}/campaign/AddLeadsToCampaignV2",
headers={"X-API-KEY": os.environ["HEYREACH_API_KEY"]},
json={"campaignId": CAMPAIGN_ID, "accountLeadPairs": pairs,
"resumeFinishedCampaign": False, "resumePausedCampaign": True})
if resp.status_code == 429:
time.sleep(30); continue
print(resp.json())Custom field names may hold only letters, digits and underscores. That is why the field is leadocean_id. Rows with no LinkedIn URL are skipped here, and Step 4 still sends them to Close.
Step 4: Create the leads in Close
Close treats a lead as a company with contacts nested inside it. Post the company as the lead and the person as its contact (Close docs, September 2026). Auth is HTTP Basic with the key as the username and an empty password.
import csv, os, requests
def close_lead(r):
return {
"name": r["current_company_name"],
"contacts": [{
"name": f'{r["profile_first_name"]} {r["profile_last_name"]}',
"title": r["current_job_title"],
"emails": [{"email": r["email_1_address"], "type": "office"}],
"urls": [{"url": r["profile_url"], "type": "url"}] if r.get("profile_url") else [],
}],
}
for r in csv.DictReader(open("export.csv")):
resp = requests.post("https://api.close.com/api/v1/lead/",
auth=(os.environ["CLOSE_API_KEY"], ""), json=close_lead(r))
if resp.status_code == 429:
print("rate limited", resp.json()) # wait rate_reset seconds, then retry
break
resp.raise_for_status()Close rate limits per endpoint group and tells you how long to wait in rate_reset (Close docs, September 2026). Pause that long on a 429, then resume. For 10,000 rows, run it in batches and keep the failed ids.
Before you move on, spot-check ten leads in Close. Confirm the contact name, the email and the LinkedIn link all landed on the right company. A bad column mapping is easier to fix at ten rows than at ten thousand.
Step 5: Send replies back to Close
HeyReach webhooks fire on events such as MESSAGE_REPLY_RECEIVED and CONNECTION_REQUEST_ACCEPTED (HeyReach docs, September 2026). Point one at a small endpoint you host.
curl -s "https://api.heyreach.io/api/public/webhooks/CreateWebhook" \
-H "X-API-KEY: $HEYREACH_API_KEY" -H "content-type: application/json" \
-d '{"webhookName":"reply-to-close","webhookUrl":"https://example.com/heyreach","eventType":"MESSAGE_REPLY_RECEIVED","campaignIds":[]}'Your handler finds the Close lead and updates it with PUT /api/v1/lead/{id}/, setting status_id to your "Replied" status. Close recommends status_id over the status label so renames do not break the call.
Test the handler with one lead you own. Reply from a second account, watch the webhook fire, and check the Close status changed. Only then switch the campaign on for the full list.
Step 6: Pace the send by sender
Plan the send rate before you load 10,000 contacts. HeyReach prices per sender, and each sender is one connected LinkedIn account, so volume scales with the number of accounts you connect. LinkedIn sets how many invites and messages an account can send, and HeyReach's own docs and settings are the place to read the current figures. Do not load all 10,000 rows into one sender and expect them to go out in a week.
A practical split is to export in slices of 1,000 to 2,000 rows, one slice per week per sender. You read replies before the next slice goes, and you can change the message when the first slice shows what works. LeadOcean refreshes its dataset monthly, and each record carries its own fetched_at date.
contacts, senders, per_slice = 10_000, 1, 2_000
weeks = contacts / (senders * per_slice)
print(f"{weeks:.0f} weeks at {per_slice} rows a week per sender")It prints 5 weeks for one sender. Add senders to shorten it.
Email stays useful alongside LinkedIn. The verified address in the export lets your Close rep follow up by email on anyone who accepts a connection but does not reply. Check email_1_status first and skip anything that is not verified.
Step 7: Cost a 10,000-contact run and know where each tool stops
Prices are monthly list first, billed-yearly after. All figures checked September 2026.
leadocean_solo = 97 # flat, fair-use pace, no per-record price
heyreach_growth = (79, 63) # per sender: monthly, billed yearly
close_essentials = (49, 35) # per user: monthly, billed yearly
print("Monthly list:", leadocean_solo + heyreach_growth[0] + close_essentials[0])
print("Billed yearly:", leadocean_solo + heyreach_growth[1] + close_essentials[1])| Tool | Plan | Monthly list | Billed yearly | Note |
|---|---|---|---|---|
| LeadOcean | Solo | $97 | n/a | 10,000 records, export included |
| HeyReach | Growth | $79 per sender | $63 per sender | pricing |
| Close | Essentials | $49 per user | $35 per user | pricing |
| Total | $225 | $195 | One sender, one rep |
Two limits matter. Close Solo ($19 monthly, $9 billed yearly) caps you at 10,000 leads and has no workflows, so 10,000 contacts fits only until the next list. Close Growth ($109 monthly, $99 billed yearly) adds workflows and the power dialer. HeyReach prices per sender, so contacting 10,000 people faster means more senders at $79 each.
Where each tool stops:
- LeadOcean stops at the list. It has an export app, not a sequencer, dialer, CRM or Chrome extension.
- HeyReach stops at the LinkedIn and sequence layer. It does not hold your pipeline or call recordings.
- Close stops at what your team enters. It has no data source of its own and does not find people.
What you get
The Step 1 count, run on 2026-10-21 with the free leadocean_count_leads tool. Filters: jobLevel VP, jobFunction Sales & Business Development, country US, employeeRange 51-200.
{
"total": 18567,
"totalIsExact": true,
"totalAll": 60456,
"distinctCompanies": 12192,
"credits": 0
}The 18,567 is mailable people, counted by the default verified, catch_all_valid or catch_all email status. It is not a verified-only count. totalAll is the same filters with no email condition.
One row of the export, response shape from the API reference, placeholder values:
person_id,profile_first_name,profile_last_name,current_job_title,current_company_name,profile_url,email_1_address,email_1_status
lo_0000,Jane,Doe,VP Sales,Acme,https://www.linkedin.com/in/jane-doe,jane@acme.com,verifiedTroubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 from LeadOcean | Missing or wrong x-api-key | Copy the key from app.leadocean.io and check $LEADOCEAN_API_KEY is set |
| 429 from LeadOcean | Above the plan's rate limit: 5 a second on Free, 200 on Agency | Wait for Retry-After, or slow the loop |
| 402 from LeadOcean | Fewer records left than the export needs | Cut limit, or move to Solo or Agency |
| Zero results | An enum value is not an exact string, such as vp for VP | Check GET /v1/enums/{name} and copy the string |
| Export refused with 400 or 429 | Over 50,000 rows a request or 500,000 a day | Split into several exports across days |
HeyReach returns failedLeadsCount above 0 | A required lead field is missing or a custom field name has a bad character | Send empty strings for blanks and keep field names to letters, digits and underscores |
FAQ
Can I run this on the Free plan?
Yes, for a test. Free has 1,000 records and no card, but no bulk CSV export. Search costs 1 record a person and the enrich that reveals the address costs 1 more, so 1,000 records cover about 500 people. Then run the same push scripts.
Why export instead of search then enrich?
Search returns email flags, not addresses. An export bills one record per row and writes the address into the file, so one pass gives you the list and the email.
Do I need Close if I only use LinkedIn?
No. HeyReach alone holds leads and conversations. Add Close when you want email, calls, tasks and a pipeline in one place.
How do I avoid contacting the same person twice?
Keep the leadocean_id custom field in HeyReach and the LinkedIn URL on the Close contact. Before each new export, drop any row whose LinkedIn URL already exists in Close. HeyReach reports updatedLeadsCount when a lead it already holds is sent again, so a repeat shows up in the response and not as a duplicate send.
Should I enrich after the export?
Only for gaps. The export columns already carry the current role and email, so a second pass spends records for no gain. Enrich one person with POST /v1/people/enrich when a single row comes back thin, and note that an enrichment that finds nothing costs nothing.
See LeadOcean + VerifyHQ + ReachHQ for cold email, LeadOcean + Smartlead + Pipedrive for a sequencer and CRM pair, and LeadOcean + n8n + Attio for a workflow build. Plans are on /pricing.
Build the list for your LinkedIn and Close stack
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →