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

How-to guide

How to Build a LeadOcean, lemlist and HubSpot Outbound Stack (List, Verify, Send)

Export a filtered list from LeadOcean, gate the emails by status, push the safe rows into lemlist and HubSpot, and let lemlist sync activity back. Sizing is free; the CSV export runs on Solo.

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

This stack gives each tool one job. LeadOcean builds the list, a status gate removes the risky emails, lemlist sends, and HubSpot holds the contacts and the activity. The free plan sizes the list and returns search pages. The CSV export in step 2 needs Solo.

The example audience is VPs of sales in the US. Swap the filters for yours.

TL;DR

  • Time: about 90 minutes for a 200-row pilot, plus HubSpot admin time to connect lemlist and map properties.
  • Records: 200 to 400 LeadOcean records for the pilot. Sizing the list first is free. The free plan has 1,000 records, one-off, but bulk CSV export is not on Free, so the pilot export needs Solo.
  • Cost per 10,000 contacts: $97 for LeadOcean Solo plus $69 for lemlist Email, about $166 in a month at monthly list prices. HubSpot adds $0 on its free tier, which covers up to 2 users (HubSpot pricing, October 2026). Paid seats cost more.

Prerequisites

  • A LeadOcean key in $LEADOCEAN_API_KEY. Sign up at app.leadocean.io/register. No card needed. Free covers step 1; the step 2 export needs Solo ($97 a month) or Agency ($497 a month).
  • A lemlist account on the Email plan or higher, with its API key in $LEMLIST_API_KEY and an empty campaign already created. The pricing page lists "CRM integrations & API" on Email (lemlist pricing, September 2026).
  • A HubSpot account where you can import contacts and connect an app. The free tier is listed at no cost for up to 2 users (HubSpot pricing, September 2026).
  • curl and Python 3 with the requests package.

Sending is your lemlist setup: mailboxes, warm-up and copy. This guide covers the data path only. Test every step on 200 rows in a sandbox or a throwaway campaign before you scale, because a sent email cannot be recalled.

Step 1: Size the list before you spend anything

Count first. A search with count=true and limit=1 is free (LeadOcean API reference, September 2026).

bash
curl -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":["US"],"emailType":["work"],"emailStatus":["verified","catch_all_valid","catch_all"],"limit":1}'

Read meta.total. It is capped at 100,000. Filters join with AND, and several values inside one filter join with OR.

Step 2: Export the pilot list

Export 200 rows, not the whole audience. Bulk CSV export is included on Solo and Agency, not on Free. The export is asynchronous and reserves records up front (LeadOcean docs, September 2026).

bash
curl -X POST https://api.leadocean.io/v1/exports \
  -H "x-api-key: $LEADOCEAN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"vp-sales-us pilot","filters":{"jobLevel":["VP"],"jobFunction":["Sales & Business Development"],"country":["US"],"emailType":["work"],"emailStatus":["verified","catch_all_valid","catch_all"]},"limit":200,"columns":["person_id","profile_first_name","profile_last_name","profile_url","current_job_title","current_company_name","current_company_domain","email_status","email_N_address"],"caps":{"emails":1}}'

The answer is a 202 with an export id. Poll GET /v1/exports/{id} until status is done, then download the CSV.

bash
curl -H "x-api-key: $LEADOCEAN_API_KEY" -o leads.csv \
  https://api.leadocean.io/v1/exports/EXPORT_ID/download

Email columns need a key with the enrich scope. One export takes up to 50,000 rows, and an account takes 500,000 rows a day.

Step 3: Gate the emails by status

Send only the addresses you trust. LeadOcean tags every address with email_status. There is no refund for bounced emails, so this gate is your protection.

The script below splits the file in two. verified and catch_all_valid rows go to the send file. catch_all rows go to a hold file you can run through a verifier first.

python
import csv

send, hold = [], []
with open("leads.csv", newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        if not row["email_1_address"]:
            continue
        status = row["email_status"]
        if status in ("verified", "catch_all_valid"):
            send.append(row)
        elif status == "catch_all":
            hold.append(row)

for name, rows in (("send.csv", send), ("hold.csv", hold)):
    if rows:
        with open(name, "w", newline="", encoding="utf-8") as out:
            w = csv.DictWriter(out, fieldnames=rows[0].keys())
            w.writeheader()
            w.writerows(rows)

print(len(send), "to send,", len(hold), "to hold")

The numbered email column comes back as email_1_address. Check your header row once before you run this, because the script reads it by name. For the hold file, the VerifyHQ and ReachHQ stack shows one way to check catch-all rows.

Step 4: Import the contacts into HubSpot

HubSpot goes first because the CRM should hold the contact before anything is sent. If a rep opens the record mid-campaign, the history should already be there.

Import send.csv as contacts so HubSpot holds the record before lemlist touches it. HubSpot's import guide says to map a property to a unique identifier to avoid duplicates (HubSpot Knowledge Base, September 2026). For contacts, map the email address.

text
HubSpot: Data Management > Data Integration > Import data > Import a file
  Object:        Contacts
  File:          send.csv
Map columns (CSV -> HubSpot property)
  email_1_address         -> Email            (unique identifier)
  profile_first_name      -> First name
  profile_last_name       -> Last name
  current_job_title       -> Job title
  current_company_name    -> Company name
  current_company_domain  -> Website URL
  profile_url             -> LinkedIn URL (create a custom property if missing)
  person_id               -> LeadOcean person ID (custom text property)

Create the two custom properties before the import so the mapping step finds them. Keep person_id: it lets you tie a HubSpot contact back to a LeadOcean record for a later enrichment.

Step 5: Push the same rows into lemlist

Create each lead through lemlist's API. The endpoint adds a lead to a campaign and never updates an existing one. A contact already in that campaign is refused with 400 LEAD_ALREADY_IN_CAMPAIGN (lemlist API docs, September 2026).

lemlist uses Basic auth with an empty login and your API key as the password. Any extra key in the body is stored as a custom variable you can use in the copy as {{variableName}}.

python
import csv, os, time, requests

CAMPAIGN_ID = "cam_XXXXXXXX"          # your empty lemlist campaign
KEY = os.environ["LEMLIST_API_KEY"]
url = f"https://api.lemlist.com/api/campaigns/{CAMPAIGN_ID}/leads/"

with open("send.csv", newline="", encoding="utf-8") as f:
    for row in csv.DictReader(f):
        body = {
            "email": row["email_1_address"],
            "firstName": row["profile_first_name"],
            "lastName": row["profile_last_name"],
            "companyName": row["current_company_name"],
            "companyDomain": row["current_company_domain"],
            "jobTitle": row["current_job_title"],
            "leadoceanPersonId": row["person_id"],
        }
        r = requests.post(url, json=body, auth=("", KEY),
                          params={"deduplicate": "true"})
        if r.status_code == 429:
            time.sleep(int(r.headers.get("Retry-After", 2)))
        elif r.status_code >= 400:
            print(row["email_1_address"], r.status_code, r.text[:120])
        time.sleep(0.1)

lemlist limits each API key to 20 requests every 2 seconds, which is 10 a second (lemlist API docs, October 2026). The 0.1 second pause stays inside that. deduplicate=true refuses a contact that already has a lead in another campaign, with a 409.

Run 20 rows first and open the campaign. Check that the variables show in the preview and that no row has an empty first name. Then run the rest. Leave the campaign paused until the sync in step 6 is on.

Step 6: Connect the HubSpot sync and cost a 10,000-contact run

Connect lemlist to HubSpot so replies and bounces land on the contact. lemlist's integration page says emails sent, opened, clicked, replied and bounced are logged on the HubSpot contact, and that you pick which activity types sync (lemlist, September 2026). It also says leads can exit a campaign when a lifecycle stage changes or a deal is created.

text
lemlist: Settings > Integrations > HubSpot > Connect
  Activity sync:   emails sent, replied, bounced (turn off opens to keep timelines clean)
  Field mapping:   lemlist variable -> HubSpot property
                     jobTitle           -> Job title
                     leadoceanPersonId  -> LeadOcean person ID
  Exit rule:       stop the sequence when the HubSpot lifecycle stage changes
  Test first:      sync one contact and open its timeline

The menu names above follow the integration page's feature list. lemlist's page does not give the click path, so open the integration card in your account and match the options to this list. lemlist's pricing page lists "CRM integrations & API" on the Email plan (lemlist pricing, October 2026).

Now price the run. This script is arithmetic from list prices, not a quote.

python
contacts = 10_000
emails_per_contact = 3            # a three-step sequence
leadocean_solo = 97               # flat per month, fair-use pace, no per-record price
lemlist_email_monthly = 69        # monthly list price, 50,000 emails a month
lemlist_email_yearly = 55         # billed yearly, per month
hubspot_free = 0                  # free CRM, up to 2 users

emails = contacts * emails_per_contact
assert emails <= 50_000           # lemlist Email plan cap

print("Emails sent:", emails, "of 50,000")
print("Monthly list:", leadocean_solo + lemlist_email_monthly + hubspot_free)
print("lemlist yearly view:", leadocean_solo + lemlist_email_yearly + hubspot_free)
print("Per contact:", round((leadocean_solo + lemlist_email_monthly) / contacts, 4))

It prints 30,000 emails, $166 a month at monthly list prices, $152 with lemlist billed yearly, and about $0.0166 a contact. The 50,000 email cap and both prices come from lemlist pricing (September 2026). Paid HubSpot seats are extra.

LeadOcean Solo is $97 a month flat with a fair-use pace instead of credits, and 10,000 contacts use 10,000 to 20,000 records. Read the pace limits on the pricing page. Agency is $497 a month if a team shares the keys.

Where each tool stops:

  • LeadOcean stops at the list. It has no sequencer, dialer, CRM or Chrome extension. email_status is a signal, not a guarantee.
  • lemlist stops at sending and its own lead data. You bring the list, and its API creates leads but never updates them.
  • HubSpot stops at the record. It does not supply the contact data, and paid seats and onboarding change the bill.

What you get

Step 1 is a free count. We ran the same audience through LeadOcean's MCP leadocean_count_leads tool on 2026-10-21.

text
jobLevel VP, jobFunction Sales & Business Development, country US,
emailType work, emailStatus verified + catch_all_valid + catch_all
total 107512   totalIsExact true   distinctCompanies 48519   credits 0

That is a count of work emails in those three statuses, not of every VP of sales in the US. The gate in step 3 then holds back the catch_all rows. The REST call in step 1 reports this audience as 100,000, its cap, with totalIsExact false.

The export create call answers with the shape below. This is the response shape from the API reference (openapi.json), with placeholder values.

json
{
  "success": true,
  "data": {
    "id": "EXPORT_ID",
    "name": "vp-sales-us pilot",
    "status": "queued",
    "requested": 200,
    "estimatedTotal": 107512,
    "perRow": 1,
    "remaining": null,
    "pollAfter": 5
  }
}

requested is the records reserved and perRow is 1 or 2. remaining is null on a paid plan, which has a pace rather than a balance. Read perRow before you scale up.

Troubleshooting

ErrorCauseFix
401 "Missing or malformed x-api-key"The header is missing or wrong.Send x-api-key: $LEADOCEAN_API_KEY.
402 on export createFewer records are left than the export reserves.Lower limit. On Free the 1,000 records are one-off, and bulk CSV export is not on Free.
429 from LeadOceanMore requests a second than your plan allows (5 on Free, 50 on Solo, 200 on Agency), or an export ceiling.Honour Retry-After. Exports allow 500,000 rows a day.
Zero rows backA filter value is outside the catalogue, such as United States for US.Read the valid values from GET /v1/enums.
400 on export createlimit is over 50,000, or a filter cannot be applied.Split into several exports with narrower filters.
400 LEAD_ALREADY_IN_CAMPAIGN from lemlistThe email already has a lead in that campaign.Skip it. The endpoint never updates, so use lemlist's update lead endpoint for changes.

FAQ

Do I need HubSpot at all?

Not to send. lemlist can run the campaign from the API push in step 5 alone. HubSpot earns its place when reps and managers need the replies and the company record in one CRM.

Why import to HubSpot before lemlist?

It puts the contact in the CRM before the first email, so the sync has a record to attach activity to. lemlist's page says it deduplicates by email, but check one test contact before a full run.

How many records does a full run cost?

One export row costs one record, or two when the create response shows perRow of 2. Solo at $97 a month and Agency at $497 a month have no per-record price, only a fair-use pace. See pricing.

Can I use a different sender?

Yes. The Instantly and HubSpot stack wires the same list step to another sender, and the Smartlead and Pipedrive stack does it for a different CRM. Back to the blog hub.

Why not use lemlist's own lead database?

You can. lemlist's pricing page lists a 650M+ leads database and a lead enrichment add-on (lemlist pricing, September 2026). LeadOcean is the better fit when you want a flat plan with filters you control and a CSV you own.

Build your first LeadOcean list for lemlist and HubSpot. Start free.

Free to start. No credit card. 1,000 records to spend whenever you like.

Get your free API key →