You will end up with a script that reads contacts from HubSpot, enriches each one by email through the LeadOcean API, and writes the result back to custom properties. It runs from a terminal and needs no extra tool. This is the version a technical buyer can read in five minutes and rerun on a schedule.
TL;DR
- This is not an 11-tool ranking: we ran no such test. The part that matters is the pipeline: read, enrich, write back. It is four HTTP calls.
- It takes about 20 minutes for the first 100 contacts. It spends up to 100 LeadOcean records, from the 1,000 on the free plan.
- A contact we hold nothing for returns a 404 and costs nothing. Size your list first with a free count.
Prerequisites
- A LeadOcean API key, exported as
$LEADOCEAN_API_KEY. The free plan has 1,000 records for life, no card, 5 requests a second. - A HubSpot private app token with contacts read and write scopes, exported as
$HUBSPOT_TOKEN. - Two custom contact properties in HubSpot:
leadocean_phoneandleadocean_email_status, both single-line text. curlandjqon your machine.
Step 1: Size the list for free
Know how many records a run can spend before it starts. The leadocean_count_leads MCP tool and the search count call both cost nothing. For a plain list of people, send count=true as a query parameter and limit 1 in the body.
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": ["Director"], "country": ["US"], "limit": 1}'The meta.total field is capped at 100,000. For contacts you already hold, the count is simply the rows you plan to send. One enrichment spends one record when it finds the person.
Step 2: Pull contacts from HubSpot
List contacts with their email and current values. HubSpot returns up to 100 contacts in one request and pages with the after value from paging (HubSpot contacts API, October 2026).
curl -s "https://api.hubapi.com/crm/v3/objects/contacts?limit=100&properties=email,leadocean_email_status" \
-H "Authorization: Bearer $HUBSPOT_TOKEN" \
| jq -c '.results[] | select(.properties.leadocean_email_status == null) | {id, email: .properties.email}' \
> contacts.jsonlThe select keeps only contacts with no leadocean_email_status yet. Every matched contact gets a status written back, so a second pass skips rows it already enriched and spends nothing on them. Add after to page past the first 100.
Step 3: Enrich one contact by email
The enrich endpoint takes exactly one of linkedin_url, email, phone or person_id. There is no name plus domain lookup, so email is the key to use for CRM rows. Set reveal_email to true to get the contact points back.
curl -s -X POST https://api.leadocean.io/v1/people/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"email": "jane.doe@acme.com", "reveal_email": true}'If you only have a name and a company, search by domain first, then enrich by person_id. Use GET /v1/companies/{domain}/people or /v1/people/search for that.
Step 4: Write the result back to HubSpot
HubSpot updates one contact by Record ID with a PATCH to /crm/v3/objects/contacts/{contactId}, and batches updates with /batch/update (HubSpot contacts API, October 2026). Use custom properties so you never overwrite a number a rep typed in.
curl -s -X PATCH "https://api.hubapi.com/crm/v3/objects/contacts/101" \
-H "Authorization: Bearer $HUBSPOT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"properties": {"leadocean_phone": "+1 415 555 0133", "leadocean_email_status": "verified"}}'Step 5: Run it over the whole file
This loop reads contacts.jsonl, enriches each row, and patches HubSpot only when LeadOcean returns a match. It sleeps 0.25 seconds between calls to stay under the Free plan's 5 requests a second.
#!/usr/bin/env bash
set -u
while read -r row; do
id=$(jq -r '.id' <<<"$row")
email=$(jq -r '.email' <<<"$row")
resp=$(curl -s -w '\n%{http_code}' -X POST https://api.leadocean.io/v1/people/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "Content-Type: application/json" \
-d "{\"email\": \"$email\", \"reveal_email\": true}")
code=$(tail -n1 <<<"$resp")
body=$(sed '$d' <<<"$resp")
case "$code" in
200) ;;
404) echo "no match: $id"; sleep 0.25; continue ;;
402) echo "out of records, stopping"; break ;;
429) echo "rate limited, slow down"; sleep 2; continue ;;
*) echo "error $code on $id"; continue ;;
esac
phone=$(jq -r '.data.contact_data.contact_phones[0].phone // empty' <<<"$body")
status=$(jq -r '.data.contact_data.email_status // empty' <<<"$body")
curl -s -o /dev/null -X PATCH "https://api.hubapi.com/crm/v3/objects/contacts/$id" \
-H "Authorization: Bearer $HUBSPOT_TOKEN" -H "Content-Type: application/json" \
-d "{\"properties\": {\"leadocean_phone\": \"$phone\", \"leadocean_email_status\": \"$status\"}}"
echo "updated: $id"
sleep 0.25
done < contacts.jsonlTest on 10 rows first with head -n 10 contacts.jsonl > test.jsonl. Read email_status before you send: there is no refund for bounced emails.
Step 6: Batch the write-back
One PATCH per contact is fine for a few hundred rows. For thousands, collect results into a file and send them in batches. HubSpot's batch update takes each contact's Record ID as id and the properties to change, up to 100 records a request (HubSpot contacts API, October 2026).
curl -s -X POST https://api.hubapi.com/crm/v3/objects/contacts/batch/update \
-H "Authorization: Bearer $HUBSPOT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"inputs": [
{"id": "101", "properties": {"leadocean_phone": "+1 415 555 0133", "leadocean_email_status": "verified"}},
{"id": "102", "properties": {"leadocean_phone": "+1 415 555 0134", "leadocean_email_status": "catch_all"}}
]}'Enrichment is the slow half, so batching the HubSpot side saves little time. It saves HubSpot API calls, which is the only gain.
Step 7: Put it on a schedule
Run the script from cron on any server once a day. The select filter in Step 2 means each run only touches contacts that are still empty, so new form fills get enriched the next morning and old rows cost nothing.
0 6 * * * cd /opt/hubspot-enrich && ./pull.sh && ./enrich.sh >> run.log 2>&1The dataset is refreshed monthly, and each record carries its own fetched_at date. Re-enriching the same contact every day buys you nothing. Re-run a full pass once a month at most.
Choosing between the script and a bigger tool
A script is right when you have an engineer, a few thousand contacts and a rule for what to write back. It breaks down when a RevOps team wants a visual workflow with branching. Then put a no-code tool that can send HTTP requests in front of HubSpot and call the same LeadOcean endpoint from it. LeadOcean has no rep workspace, sequencer or HubSpot app of its own, so this path is code or a no-code tool you already own.
For one-off lists of net-new people rather than contacts you hold, skip the loop. Build the list on the Exports page of the app at app.leadocean.io, or send POST /v1/exports for up to 50,000 rows a request. Then import the CSV into HubSpot. Bulk CSV export is not on the Free plan.
What you get
A free count run on 21 October 2026 shows what a filter set looks like before you spend anything. The query was jobLevel Director in the United States, counted with the tool's default mailable filter (verified, catch_all_valid or catch_all):
{
"total": 4798296,
"totalIsExact": true,
"totalAll": 12741708,
"distinctCompanies": 693716,
"credits": 0
}totalAll is the same filters with no email filter. credits is 0 because a count is free.
The enrich call returns the shape below. It is the response shape from the API reference, with placeholder values.
{
"success": true,
"data": {
"contact_data": {
"has_email": true,
"has_phone": true,
"email_status": "verified",
"email_type": "work",
"contact_emails": [
{ "email": "jane.doe@acme.com", "type": "work", "status": "verified" }
],
"contact_phones": [
{ "phone": "+14155550133", "type": "mobile" }
]
}
},
"meta": {}
}Since 17 October 2026, the same call also returns resume_data: past roles, education, certifications and awards, each when held.
Past employers carry no company_id yet.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header, or $LEADOCEAN_API_KEY not exported in this shell | Run echo ${LEADOCEAN_API_KEY:0:4} to confirm it is set. Use the key from the app, not a HubSpot token. |
| 404 on enrich | LeadOcean holds nothing for that email | Costs nothing. Skip the row or retry with linkedin_url. |
| 429 | You sent more than your plan's rate limit: 5 a second on Free, 200 on Agency | Honor the Retry-After header or raise the sleep in the loop. |
| 402 | The free 1,000 records are spent | Move to Solo at $97 a month or Agency at $497 a month. See pricing. |
| Empty search results | A filter value that is not an exact string, such as director where the API wants Director | List valid values with GET /v1/enums or leadocean_list_enum_values. |
| Export refused | More than 50,000 rows in one request, or 500,000 rows in a day | Split the job. Bulk CSV export is not on the Free plan. |
What to write back, and what not to
Write back only fields a rep will act on. Phone and email_status are the two that change what someone does next: call, or do not email. Skip anything you will never filter a HubSpot view on, because every extra property is another column nobody reads.
Do not overwrite email itself. HubSpot's docs call email the primary unique identifier for a contact, so changing it is not a cosmetic edit. Store the revealed work email in its own property if it differs, and let a human merge.
Check email_type before you trust an address. A value of work_other means a former employer, and that is a reason to flag the contact, not to send.
FAQ
Do I need all 11 tools from the roundups?
No. This pipeline uses one data source and one script. For a side-by-side of vendors, read best enrichment tools for HubSpot.
How many records does a 10,000-contact HubSpot database spend?
Up to 10,000, one per contact we find. Contacts we hold nothing for cost nothing, so the real number is lower. On a paid plan the pace is fair use, not credits: nothing stops and nothing expires.
Can I enrich phone numbers only?
Yes. GET /v2/people/phone returns numbers alone. Our guide to testing phone enrichment tools covers how to judge direct-dial quality.
Does this work for other CRMs?
The LeadOcean half is identical. Only Step 2 and Step 4 change. See the Keap version of this guide for that side.
Can an AI agent do this instead of a script?
Yes. The hosted MCP server at https://api.leadocean.io/mcp exposes 13 tools, including free counts and enum lookups, to any MCP client.
Enrich your HubSpot contacts with a free LeadOcean key
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →