TL;DR
- List Freshsales contacts that have an email but no job title, enrich each email with one LeadOcean call, and write results back with
bulk_upsert, 100 contacts a request. - A first run over 100 contacts takes about 45 minutes and spends at most 100 of your 1,000 free records. A miss is a 404 and costs nothing.
- You do not need eight tools. You need one data source behind Freshsales' API and a script you can read.
Searches for "Freshsales enrichment tools" return roundups of eight or ten logos. We have not run a head-to-head test of eight tools, so this page does not rank them. It shows the one loop a technical buyer can verify.
Freshworks' CRM API takes Authorization: Token token=... and caps an account at 1000 API requests per hour (October 2026). Bulk upsert takes 100 contacts a call. That makes a small loop cheap.
LeadOcean has no Freshsales app and no Chrome extension. This is a script against two APIs. For the general idea, read what enrichment is. For the same recipe on another CRM, see Zoho CRM.
Prerequisites
- A LeadOcean API key from app.leadocean.io. The free plan has no card and 1,000 records, one-off. Free keys can enrich.
- A Freshworks CRM API key (in your profile settings) and your bundle alias, which gives the domain
https://YOURALIAS.myfreshworks.com(October 2026). - Python 3 with
requests.
export LEADOCEAN_API_KEY="paste-your-key-here"
export FRESH_KEY="paste-your-freshworks-api-key-here"
export FRESH_BASE="https://yourbundle.myfreshworks.com/crm/sales/api"
pip install requestsStep 1: List contacts that need a title
Freshsales lists contacts through a saved view. Fetch the filter ids first, then page through a view.
curl -s -H "Authorization: Token token=$FRESH_KEY" \
-H "Content-Type: application/json" \
"$FRESH_BASE/contacts/filters"Pick a view id (for example "All Contacts") from the response. Pages hold 25 contacts by default and you add page=2 and so on (October 2026).
import os, requests
BASE = os.environ["FRESH_BASE"]
FH = {"Authorization": f"Token token={os.environ['FRESH_KEY']}",
"Content-Type": "application/json"}
def missing_titles(view_id, max_pages=4):
out = []
for page in range(1, max_pages + 1):
r = requests.get(f"{BASE}/contacts/view/{view_id}",
headers=FH, params={"page": page}, timeout=30)
r.raise_for_status()
rows = r.json().get("contacts", [])
if not rows:
break
out += [c for c in rows if c.get("email") and not c.get("job_title")]
return outEach page is one of your 1000 hourly requests. Four pages cover 100 contacts.
Step 2: Enrich each email
Enrich takes exactly one of linkedin_url, email, phone or person_id. Send the Freshsales email with reveal_email set to true, since phone numbers come back only with it. Revealing costs no extra record.
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@acme.com", "reveal_email": true}'A 404 means we hold nothing for that person. It is not billed. Skip the row.
LO = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "content-type": "application/json"}
def enrich(email):
r = requests.post("https://api.leadocean.io/v1/people/enrich",
headers=LO, json={"email": email, "reveal_email": True}, timeout=30)
if r.status_code == 404:
return None
if r.status_code in (401, 402):
raise SystemExit(f"stop: {r.status_code} {r.text[:120]}")
r.raise_for_status()
return r.json()["data"]["contact_data"]Step 3: Bulk upsert the results
Freshsales' bulk_upsert takes up to 100 contacts a request. Each item names one unique identifier and a data hash. Use the contact id, which the docs allow (October 2026). Up to 10 bulk calls can run at once per account.
def build_row(contact, cd):
exp = (cd.get("contact_current_experiences") or [{}])[0]
data = {}
if exp.get("job_title"):
data["job_title"] = exp["job_title"]
phones = [p["phone"] for p in cd.get("contact_phones") or []]
if phones and not contact.get("mobile_number"):
data["mobile_number"] = phones[0]
return {"id": str(contact["id"]), "data": data} if data else None
def push(rows):
r = requests.post(f"{BASE}/contacts/bulk_upsert", headers=FH,
json={"contacts": rows}, timeout=60)
r.raise_for_status()
return r.json()Freshsales tracks bulk calls through its Job Status API. Check the job before you trust the write.
Step 4: Dry run on 20 contacts
Print what would change and write nothing. Then switch writes on.
import time
DRY = True
rows, spent = [], 0
for c in missing_titles(VIEW_ID)[:20]:
cd = enrich(c["email"])
time.sleep(0.3)
if cd is None:
continue
spent += 1
row = build_row(c, cd)
print(c["id"], "->", row)
if row:
rows.append(row)
if rows and not DRY:
push(rows)
print(f"records spent: {spent} rows to write: {len(rows)}")Set VIEW_ID to the id from Step 1. On Free the LeadOcean limit is 5 requests a second, so a sequential loop with a pause stays under it.
Step 5: Find contacts Freshsales does not have yet
Enrichment fixes rows you hold. To add rows, size the list first. The count is free.
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": ["VP"], "jobFunction": ["Sales & Business Development"], "country": ["DE"], "limit": 1}'Run the same body without count, with limit up to 100 and the cursor from meta for the next page. Create the results in Freshsales through its contacts API. Paid plans can also use POST /v1/exports for up to 50,000 rows a request and 500,000 a day, then import the CSV.
What you get
The dry run prints one line per contact. This is the format with placeholder values.
1001 -> {'id': '1001', 'data': {'job_title': 'Head of Sales', 'mobile_number': '+1 415 555 0133'}}
1002 -> {'id': '1002', 'data': {'job_title': 'VP Marketing'}}
records spent: 2 rows to write: 2The enrich call returns this shape. It is the response shape from the API reference, with placeholder values.
{
"success": true,
"data": {
"contact_data": {
"email_status": "verified",
"contact_phones": [{ "phone": "+14155550133", "type": "mobile" }],
"contact_current_experiences": [
{ "company_name": "Acme", "company_domain": "acme.com", "job_title": "Head of Sales" }
]
}
}
}Sizing run on 2026-10-21 with leadocean_count_leads: jobLevel VP, jobFunction Sales & Business Development, country DE. It returned 1,395 people across 905 employers. The tool's default counts only verified, catch_all_valid and catch_all addresses, so that is a mailable count. The same filters with no email condition match 3,893.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 from LeadOcean | Missing or wrong x-api-key. | Re-export $LEADOCEAN_API_KEY from app.leadocean.io. |
| 402 | Out of records. On Free that is after your 1,000. | Read GET /v1/account, then move to Solo at $97 a month. |
| 429 from LeadOcean | Above the plan's rate limit: 5 a second on Free, 200 on Agency. | Sleep and retry. A sequential loop will not hit it. |
| 404 on enrich | We hold nothing for that email. | Not billed. Skip the row. |
| 429 from Freshsales | Over 1000 API requests per hour per account. | Batch writes at 100 and cap pages per run. |
| Empty search results | A bad enum value, such as vp for jobLevel. | Use exact strings: VP, Director, C-Team. Check leadocean_list_enum_values. |
FAQ
Does LeadOcean have a native Freshsales integration?
No. There is no Freshsales app, and no Chrome extension. You call both APIs from a script, a scheduled job or a workflow tool.
Did you test eight Freshsales enrichment tools?
No. We have not run that test, so we do not rank eight tools. For how stacking sources works, read what is waterfall enrichment.
Will this overwrite titles my team typed in?
Not as written. Step 1 keeps only contacts with no job_title, and Step 3 sends a phone only when Freshsales holds none.
What does it cost after the free 1,000?
Solo is $97 a month for one person. Agency is $497 a month for teams, with 200 requests a second. Paid plans have a fair-use pace, not credits. See pricing.
Fill your Freshsales contacts with titles and phones
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →