You get a nightly job that fills the gaps in Zoho CRM. It reads leads that have a LinkedIn URL, sends each to LeadOcean, and writes the work email, email status, phone and job title back through Zoho's upsert API. LeadOcean is a plain REST API, so any script that can call two HTTP endpoints does it. The free plan covers a first run of 1,000 records.
Related: the integrations hub, Retool, Notion, Folk and pricing.
How it works
The job has three parts: read from Zoho, enrich at LeadOcean, upsert back to Zoho. It runs as an external script, outside Zoho, using only Zoho's documented REST API for reads, upserts and limits.
- Get a LeadOcean key. Sign up at app.leadocean.io, open API keys and copy one.
- Add two custom fields in Zoho. Create
LinkedIn_URLandEmail_Statuson the Leads module. Tick "Do not allow duplicate values" onLinkedIn_URL. That makes it a user-defined unique field, which Zoho's upsert can match on. - Create a Zoho API client. Register a self-client in the Zoho API console and generate a token with scope
ZohoCRM.modules.ALL(or the narrower leads scope). Zoho's docs describe web and self-client grants. - Read leads. Call Get Records on Leads with the
fieldsparameter andper_page=200. Page withpage_tokenpast 2,000 records. - Enrich each lead. Call
POST /v1/people/enrichwith thelinkedin_urlandreveal_email: true. A person LeadOcean holds nothing for returns 404 and costs nothing. - Upsert in batches of 100. Zoho allows at most 100 records per upsert call. Set
duplicate_check_fieldsto["LinkedIn_URL"]. - Schedule it. Run the script from cron or any scheduler once a night. Select only leads where
Emailis empty so each lead is enriched once. Leads with no match stay empty, so add a date field such asEnriched_Onand skip rows checked in the last 30 days.
Configuration
The script below is the whole job. Set LEADOCEAN_API_KEY and ZOHO_TOKEN in the environment. Zoho access tokens expire, so refresh them with your refresh token before each run. The www.zohoapis.com host is for the US data centre; use your own region's API domain.
import os, requests
LO = "https://api.leadocean.io"
ZOHO = "https://www.zohoapis.com/crm/v8"
lo_headers = {"x-api-key": os.environ["LEADOCEAN_API_KEY"]}
zo_headers = {"Authorization": "Zoho-oauthtoken " + os.environ["ZOHO_TOKEN"]}
r = requests.get(f"{ZOHO}/Leads", headers=zo_headers, params={
"fields": "id,Last_Name,Email,LinkedIn_URL", "per_page": 200})
leads = [l for l in r.json().get("data", [])
if l.get("LinkedIn_URL") and not l.get("Email")]
batch = []
for lead in leads:
e = requests.post(f"{LO}/v1/people/enrich", headers=lo_headers,
json={"linkedin_url": lead["LinkedIn_URL"], "reveal_email": True})
if e.status_code == 404:
continue # nobody matched, nothing billed
e.raise_for_status()
d = e.json()["data"]
emails = d["contact_data"].get("contact_emails") or []
phones = d["contact_data"].get("contact_phones") or []
batch.append({
"LinkedIn_URL": lead["LinkedIn_URL"],
"Email": emails[0]["email"] if emails else None,
"Email_Status": emails[0]["status"] if emails else None,
"Phone": phones[0]["phone"] if phones else None,
"Designation": (d["contact_data"].get("contact_current_experiences")
or [{}])[0].get("job_title"),
})
for i in range(0, len(batch), 100):
requests.post(f"{ZOHO}/Leads/upsert", headers=zo_headers, json={
"data": batch[i:i+100],
"duplicate_check_fields": ["LinkedIn_URL"]}).raise_for_status()Test the key first with one call:
curl -X POST https://api.leadocean.io/v1/people/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"linkedin_url": "https://www.linkedin.com/in/jane-doe", "reveal_email": true}'Send exactly one identifier per call: linkedin_url, email, phone or person_id. reveal_email: true costs no extra record. The script needs a Last_Name on each lead only if you create new ones.
Field mapping
The enrich response has success, data and meta. Emails, phones and current roles are arrays, so the script takes the first item. Field paths are from the enrich docs, October 2026. Designation is the API name of the Title field on Zoho Leads.
| LeadOcean field | Zoho field | Note |
|---|---|---|
data.contact_data.contact_emails[0].email | Email | Only returned with reveal_email: true. |
data.contact_data.contact_emails[0].status | Email_Status (custom) | Email status has 13 values. Mail only verified and catch_all_valid. |
data.contact_data.contact_phones[0].phone | Phone | Only returned with reveal_email: true. |
data.contact_data.contact_current_experiences[0].job_title | Designation (Title) | Current role only. |
data.profile_data.profile_full_name | Not mapped | Zoho already holds first and last name. |
LinkedIn_URL (input) | LinkedIn_URL (custom, unique) | The dedupe key for the upsert. |
Cost at 10,000 rows
Enriching 10,000 Zoho leads costs LeadOcean one Solo month at $97, and about 1,050 of Zoho's daily API credits. The Free plan covers the first 1,000 records, once.
| Item | Free | Solo ($97 a month) |
|---|---|---|
| LeadOcean records | 1,000 for life | Fair-use pace: 10k enrichments every 5 hours, 50k a week |
| Rate limit | 5 requests a second | 50 requests a second |
| 10,000 rows | Covers the first 1,000 | One run fits the pace |
Rows with no match are 404s and cost nothing, so 10,000 is a ceiling. Paid plans have no credits and no overage invoice. Agency at $497 a month suits teams that enrich across many sellers.
Zoho's side is its own credit system (Source: Zoho API limits, September 2026). Each API call costs 1 credit. Upsert costs 1 credit per 10 records. So reading 10,000 leads at 200 a page is 50 credits, and upserting them is 1,000 credits. The Free edition allows 5,000 credits in a 24-hour window, so one night's run fits.
Zoho seats are the larger bill, and this job does not add any. Zoho lists a Free edition for 3 users and paid editions priced per user a month (Zoho pricing, October 2026). Paid editions also get more API credits: Standard starts at 50,000 a day plus 250 per user license (Zoho API limits, October 2026).
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Upsert inserts a duplicate | LinkedIn_URL is not marked "Do not allow duplicate values". | Mark the field unique, then list it in duplicate_check_fields. |
| Upsert rejects the call | More than 100 records in data. | Chunk the batch at 100. |
INVALID_DATA error | A value is longer than the Zoho field allows. | Raise the field length or trim the title. |
| 400 from LeadOcean | Two identifiers or none in the body. | Send only linkedin_url. |
| 401 from Zoho | The access token expired. | Refresh it with your refresh token before each run. |
| 404 from LeadOcean | LeadOcean holds nothing for that person. | Skip the row. It was not billed. |
| 429 from LeadOcean | You went over your plan's request rate. | Back off for the Retry-After seconds. |
| Only 2,000 leads read | Page numbers stop at 2,000 records. | Use page_token from next_page_token. |
FAQ
Does LeadOcean have a Zoho CRM app?
No. LeadOcean is a REST API with an x-api-key header. There is no listing in Zoho's marketplace, so you call it from a script or a scheduler that can reach both APIs.
Can I enrich by email instead of LinkedIn URL?
Yes. Send email instead of linkedin_url. There is no name plus domain lookup. For that, search by domain first with POST /v1/people/search, then enrich by person_id.
Does enrich return past roles?
Yes. Since October 2026 the same call returns resume_data: past roles, education and certifications when LeadOcean holds them. Past employers carry no company id yet. Map them to a Zoho multi-line field if your reps want the history.
Can I get phones on their own?
Yes. GET /v2/people/phone returns numbers and costs one record per call. For a one-off list, the Exports page in the LeadOcean app does the same with a CSV download, which you can import into Zoho.
Enrich your first Zoho CRM leads with a free LeadOcean key
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →