Claude Code HubSpot automation works best as a script that Claude Code writes and you run: LeadOcean supplies the people, HubSpot's batch upsert writes them. Keep the chat for design and the script for volume.
This guide builds the first workflow end to end, then lists nine more that reuse the same three pieces.
TL;DR
- It takes about 30 minutes the first time. Each later workflow is a new filter and a new property map.
- Sizing a list is free. Each person you search or enrich spends 1 record. A 50-contact test costs up to 100 of the 1,000 free records.
- HubSpot batch operations take 100 records per request (HubSpot docs, October 2026), so 1,000 contacts is 10 write calls.
Prerequisites
- A LeadOcean API key from app.leadocean.io, stored as
$LEADOCEAN_API_KEY. Free has no card and 5 requests a second. - A HubSpot private app token in
$HUBSPOT_TOKEN, with scopes to read and write contacts. - Claude Code,
curl,jqand Python 3 withrequests.
export LEADOCEAN_API_KEY="paste-your-key-here"
export HUBSPOT_TOKEN="paste-your-token-here"
claude mcp add --transport http leadocean https://api.leadocean.io/mcpThe last line adds the LeadOcean MCP server, which signs in with OAuth and needs no pasted key. Setup details are on the Claude Code MCP page.
Step 1: Size the list before you spend anything
Ask Claude Code to count first. The leadocean_count_leads tool is free however often you call it.
Use leadocean_count_leads with title "Revenue Operations", jobLevel Director, VP and Manager, country US. Set facets=false. Tell me the total and totalAll.Claude Code calls the tool with this input:
{
"title": ["Revenue Operations"],
"jobLevel": ["Director", "VP", "Manager"],
"country": ["US"],
"facets": false
}Enum values are exact strings. If a filter returns nothing, check it with leadocean_list_enum_values before you retry.
Step 2: Pull the people with the search endpoint
Search takes a flat JSON body. Filters are arrays, limit runs from 1 to 100, and cursor pages the rest.
curl -s https://api.leadocean.io/v1/people/search \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "content-type: application/json" \
-d '{"title":["Revenue Operations"],"jobLevel":["Director","VP","Manager"],"country":["US"],"limit":50}' \
| tee page1.json | jq '.meta'Each person returned is 1 record. One search pages to 10,000 rows, and a capped page has meta.depthCapped set. Narrow the filters instead of walking further.
Before you write the mapping, look at one row:
jq '.data[0] | keys' page1.jsonThe documented top-level keys are profile_data, contact_data, resume_data and meta. Search rows carry no email addresses, only the has_email and email_status flags. Each row's meta.source_ids.person_id is what you enrich in Step 3 to get the address.
Step 3: Let Claude Code write the HubSpot upsert
Give Claude Code the shape and the rules. It writes the script, you review it, then you run it.
Read page1.json. Write upsert.py that, for each row with has_email true,
POSTs meta.source_ids.person_id to /v1/people/enrich with reveal_email true.
Take the first contact_data.contact_emails[] entry with type "work" and status
verified or catch_all_valid. Map email, firstname, lastname, jobtitle
(contact_current_experiences[0].job_title) and company (its company_name).
Post to /crm/v3/objects/contacts/batch/upsert in chunks of 100 with
idProperty "email". Cache every response. Print counts. No hardcoded tokens.The reviewed HubSpot half:
import os, requests
HEAD = {"Authorization": f"Bearer {os.environ['HUBSPOT_TOKEN']}"}
URL = "https://api.hubspot.com/crm/v3/objects/contacts/batch/upsert"
def upsert(rows):
for i in range(0, len(rows), 100):
chunk = rows[i:i + 100]
body = {"inputs": [
{"idProperty": "email", "id": r["email"], "properties": r}
for r in chunk
]}
resp = requests.post(URL, headers=HEAD, json=body)
resp.raise_for_status()
print(i, resp.status_code)
rows = [{"email": "jane.doe@acme.com", "firstname": "Jane",
"lastname": "Doe", "jobtitle": "Director of RevOps",
"company": "Acme"}]
upsert(rows)HubSpot says partial upserts are not supported with email as the idProperty, so send the full property set each time (HubSpot docs, October 2026). Use a custom unique property if you need partial updates.
Run it on 50 rows first. Open five of the new contacts in HubSpot and check the title, company and email against the source. Only then raise the limit.
Each revealed address sits in contact_data.contact_emails[] with its own type and status (enrich docs). Keep work addresses with a sendable status, because bounced emails are not refunded.
Step 4: Enrich the contacts you already have
Enrichment takes exactly one of linkedin_url, email, phone or person_id. An enrich that finds nobody is a 404 and costs nothing.
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}'Since October 2026 the response also carries career history in resume_data: past roles, education, certifications and awards. Past employers have no company_id yet. Phones come back in contact_phones[].phone.
To pull the contacts to enrich, read them from HubSpot first. The batch read takes up to 100 contacts by email with idProperty set to email.
Two habits keep this cheap. Cache every API response to a local file, so a failed HubSpot write never makes you re-spend records. And log the count of rows fetched, created and updated on every run, so you can explain any change in usage.
Step 5: Run it on a schedule, with a guard
Ask Claude Code to wrap the script with two guards: a record budget and a rate guard. HubSpot answers 429 above its limits, and a privately distributed app has its own daily cap (HubSpot docs, September 2026).
Add to upsert.py: stop if more than 500 people would be fetched in one run, sleep 0.25s between LeadOcean calls on the Free plan, and retry HubSpot 429s after the Retry-After header.Then run it by cron or a GitHub Action. Do not give the scheduled job a bigger budget than the plan's pace.
The 10 workflows
The first workflow is the persona pull you just built. The rest change one thing: the LeadOcean call, the HubSpot write, or both.
Each one is a new filter set plus a property map. Size it with leadocean_count_leads first.
| # | Workflow | LeadOcean call | HubSpot write |
|---|---|---|---|
| 1 | Net-new contacts for a persona | /v1/people/search | batch upsert |
| 2 | Fill missing emails on old contacts | /v1/people/enrich by linkedin_url | batch update |
| 3 | Fill missing phones | GET /v2/people/phone | batch update |
| 4 | Fill job title and level | enrich, contact_current_experiences[] | batch update |
| 5 | Buying committee for one account | GET /v1/companies/{domain}/people | batch upsert |
| 6 | Size a territory | GET /v1/companies/count (free) | none |
| 7 | Company firmographics | POST /v1/companies/enrich | batch update |
| 8 | Career history note on a contact | enrich, resume_data | notes or a custom property |
| 9 | Flag stale records | enrich by email, compare title | a property flag |
| 10 | Bulk list over 10,000 rows | POST /v1/exports (not on Free) | CSV import |
Workflows 2, 3, 4 and 9 start from contacts already in HubSpot, so they spend records only on people we hold. Workflow 6 spends nothing at all. Use it before any territory plan, then pass the same filters to workflow 1.
Exports take up to 50,000 rows a request and 500,000 a day. Workflows 1 to 9 spend 1 record per person or company returned.
What you get
Output of the count in Step 1, run 2026-10-21 with leadocean_count_leads:
{
"total": 5749,
"totalIsExact": true,
"totalAll": 10284,
"distinctCompanies": 3521,
"credits": 0
}That is 5,749 people with a verified, catch-all valid or catch-all email, out of 10,284 matching the same filters. The count describes the mailable audience, not everyone.
Enrich response shape from the API reference, with placeholder values:
{
"success": true,
"data": {
"profile_data": { "profile_full_name": "Jane Doe" },
"contact_data": {
"has_email": true,
"email_status": "verified",
"contact_emails": [
{ "email": "jane.doe@acme.com", "type": "work", "status": "verified" }
],
"contact_current_experiences": [
{ "job_title": "Director of RevOps", "company_name": "Acme", "company_domain": "acme.com" }
]
},
"resume_data": { "experiences": [] }
},
"meta": { "credits": 1 }
}contact_emails and contact_phones appear only with reveal_email: true. Run Step 3 on one row to see real values in your own account.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 from LeadOcean | Missing or wrong x-api-key | Re-export $LEADOCEAN_API_KEY and check the header name |
| 429 from LeadOcean | Above the plan rate limit: 5 a second on Free, 200 on Agency | Wait the Retry-After seconds, then slow the loop |
| 402 from LeadOcean | The 1,000 free records are spent | Move to Solo at $97 a month or Agency at $497 a month, see pricing |
Empty data | Enum value not exact, such as director instead of Director | Check with leadocean_list_enum_values |
| 404 on enrich | We hold nothing for that person | Nothing is charged. Try another key such as linkedin_url |
| Export refused | Over 50,000 rows a request or 500,000 a day, or on Free | Split the export, or use a paid plan |
FAQ
Can Claude Code write to HubSpot directly?
Yes, through scripts that call HubSpot's REST API with your token. This guide uses the contacts API only. Review the script before you run it.
How many records does the first run cost?
Counting is free. A 50-person search spends 50 records, and enriching them to get emails spends up to 50 more. Enriching a contact that we hold nothing for spends none.
Does LeadOcean replace HubSpot?
No. LeadOcean has no CRM, sequencer or dialer. It is the data source, and HubSpot stays the system of record.
Do I need the MCP server or the API?
The MCP server is best for sizing and one-off questions. The API is better for scheduled jobs.
Which plan fits a team?
One person doing this weekly fits Solo at $97 a month. A RevOps team with several keys fits Agency at $497 a month.
Fill HubSpot from one data source
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →