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

How-to guide

How to Enrich HubSpot Contacts (With One API Call per Contact)

The short version: a script you can read, run and bookmark.

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

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_phone and leadocean_email_status, both single-line text.
  • curl and jq on 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.

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

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

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

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

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

bash
#!/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.jsonl

Test 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).

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

bash
0 6 * * * cd /opt/hubspot-enrich && ./pull.sh && ./enrich.sh >> run.log 2>&1

The 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):

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

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

ErrorCauseFix
401Missing or wrong x-api-key header, or $LEADOCEAN_API_KEY not exported in this shellRun echo ${LEADOCEAN_API_KEY:0:4} to confirm it is set. Use the key from the app, not a HubSpot token.
404 on enrichLeadOcean holds nothing for that emailCosts nothing. Skip the row or retry with linkedin_url.
429You sent more than your plan's rate limit: 5 a second on Free, 200 on AgencyHonor the Retry-After header or raise the sleep in the loop.
402The free 1,000 records are spentMove to Solo at $97 a month or Agency at $497 a month. See pricing.
Empty search resultsA filter value that is not an exact string, such as director where the API wants DirectorList valid values with GET /v1/enums or leadocean_list_enum_values.
Export refusedMore than 50,000 rows in one request, or 500,000 rows in a daySplit 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 →