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

Outbound stack

How to run list, verify, send with LeadOcean, HeyReach and Close (a 3-tool outbound stack)

LeadOcean builds the list, HeyReach sends on LinkedIn, Close holds the deals. Setup, code and what 10,000 contacts cost.

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

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.

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

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

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

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

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

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

python
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])
ToolPlanMonthly listBilled yearlyNote
LeadOceanSolo$97n/a10,000 records, export included
HeyReachGrowth$79 per sender$63 per senderpricing
CloseEssentials$49 per user$35 per userpricing
Total$225$195One 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.

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

text
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,verified

Troubleshooting

ErrorCauseFix
401 from LeadOceanMissing or wrong x-api-keyCopy the key from app.leadocean.io and check $LEADOCEAN_API_KEY is set
429 from LeadOceanAbove the plan's rate limit: 5 a second on Free, 200 on AgencyWait for Retry-After, or slow the loop
402 from LeadOceanFewer records left than the export needsCut limit, or move to Solo or Agency
Zero resultsAn enum value is not an exact string, such as vp for VPCheck GET /v1/enums/{name} and copy the string
Export refused with 400 or 429Over 50,000 rows a request or 500,000 a daySplit into several exports across days
HeyReach returns failedLeadsCount above 0A required lead field is missing or a custom field name has a bad characterSend 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 →