TL;DR
- Add the LeadOcean MCP server to ChatGPT as a custom MCP server, paste up to 25 rows into a prompt, and ask for an enriched CSV back. Then run the full file with a script against
POST /v1/people/enrich. - It takes about 20 minutes. Each person we match spends one record. A person we hold nothing for is a 404 and costs nothing.
- A 25-row batch spends at most 25 of your 1,000 free records. A 200-row file spends at most 200. Your real number depends on the match rate.
Prerequisites
- A LeadOcean account from app.leadocean.io. The free plan has no card and 1,000 records, one-off. Its keys get both the search and enrich scopes.
- A ChatGPT account that can add a custom MCP server. OpenAI's docs, October 2026, say account and workspace policies apply.
- An API key in your shell as
$LEADOCEAN_API_KEY, for the script step only. The chat steps use OAuth and need no key. - Python 3 and
curl. - A CSV with one identifier per row: an
emailor alinkedin_url. Enrich takes exactly one oflinkedin_url,email,phoneorperson_id. There is no name plus company lookup.
export LEADOCEAN_API_KEY="paste-your-key-here"Step 1: Connect LeadOcean to ChatGPT
Add the server URL as a custom MCP server with OAuth. No key is pasted into the chat. These are the steps in OpenAI's docs, October 2026.
ChatGPT Plugins > plus button > Add custom MCP server
Name: LeadOcean
URL: https://api.leadocean.io/mcp
Authentication: OAuth
Create as a pluginSign in to LeadOcean and approve. The server exposes 13 tools. The full walkthrough, with sign-in and troubleshooting, is on Connect LeadOcean to ChatGPT.
Step 2: Prepare a small CSV
Keep the file plain. Use a header row, one person per row and no commas inside fields. The identifier column is what we match on. Name and company are for your own checking.
first_name,last_name,company,email,linkedin_url
Jane,Doe,Acme,jane.doe@acme.com,
John,Roe,Example Corp,,https://www.linkedin.com/in/john-roe-exampleThese rows are placeholders. Start with your first 25 real rows. A small batch shows your match rate before you commit the whole file.
Clean the identifier column first. Trim spaces, lowercase the emails and use full LinkedIn profile URLs. A malformed identifier comes back as not found, which costs nothing but wastes a model turn.
Step 3: Enrich a batch in chat
Start a new conversation, type @ and select the LeadOcean plugin. Paste the rows into the prompt. This avoids guessing how ChatGPT handles a file attachment.
Use the LeadOcean plugin only. Do not browse the web.
For each row below, call leadocean_get_lead with the email, or the linkedin_url
when there is no email. Skip rows with neither. Do not call any other tool that
spends records.
Return one CSV code block with the original columns plus job_title, company,
email_status and has_phone. Write not_found for rows that return nothing.
Finish with two numbers: rows matched and rows not found.
first_name,last_name,company,email,linkedin_url
Jane,Doe,Acme,jane.doe@acme.com,
John,Roe,Example Corp,,https://www.linkedin.com/in/john-roe-exampleChatGPT may ask you to confirm a tool call. Check the arguments, then approve.
A leadocean_get_lead call that finds nobody costs nothing, the same as the REST 404. Each match costs one record, so 25 rows spend at most 25. Copy the CSV block into pilot_enriched.csv.
Chat is the right tool up to a few dozen rows. Each row is a separate tool call and a model turn, so a long file gets slow and hard to check.
Step 4: Read the result before you scale
Look at three things in the batch. First, the match rate: if most rows came back empty, your identifiers are the problem, not the data. Second, the email_status column, which tells you how much of the file is safe to send. Third, whether the job titles are current enough for your use.
Fix the CSV, not the prompt, if rows failed. Statuses are exact strings, and the free leadocean_list_enum_values tool lists them. Ask ChatGPT to call it once if a value looks odd.
Step 5: Run the full file with a script
Past a few dozen rows, send one enrich request per row from a script. Pass reveal_email as true to get the addresses and phones we hold, at no extra record. The script stops on a 402, so it never loops against an empty balance.
import csv, json, os, time, urllib.request, urllib.error
KEY = os.environ["LEADOCEAN_API_KEY"]
URL = "https://api.leadocean.io/v1/people/enrich"
def enrich(body):
req = urllib.request.Request(
URL, data=json.dumps(body).encode(), method="POST",
headers={"x-api-key": KEY, "content-type": "application/json"})
try:
with urllib.request.urlopen(req) as r:
return r.status, json.load(r)
except urllib.error.HTTPError as e:
if e.code == 429:
time.sleep(int(e.headers.get("Retry-After", "1")))
return enrich(body)
return e.code, None
with open("contacts.csv", newline="") as f, open("enriched.csv", "w", newline="") as g:
rows = csv.DictReader(f)
out = csv.DictWriter(g, fieldnames=rows.fieldnames + [
"result", "email_status", "best_email", "best_phone"])
out.writeheader()
matched = 0
for row in rows:
body = {"reveal_email": True}
if row.get("email"):
body["email"] = row["email"]
elif row.get("linkedin_url"):
body["linkedin_url"] = row["linkedin_url"]
else:
out.writerow({**row, "result": "no_identifier"})
continue
code, data = enrich(body)
if code == 402:
print("Out of records. Stopping.")
break
if code != 200:
out.writerow({**row, "result": "not_found" if code == 404 else f"http_{code}"})
continue
matched += 1
c = data["data"].get("contact_data", {})
emails, phones = c.get("contact_emails") or [], c.get("contact_phones") or []
out.writerow({**row, "result": "matched",
"email_status": c.get("email_status") or "",
"best_email": emails[0]["email"] if emails else "",
"best_phone": phones[0]["phone"] if phones else ""})
print(f"Matched {matched} rows. Records spent: {matched}.")Run it with python3 enrich.py. The printed match count is your record cost. The script sends one request at a time, below even the Free rate of 5 requests a second, and writes a row for every input.
Step 6: Check the cost
GET /v1/account is free. It returns records used and remaining, so you can compare it with the script's count.
curl -s https://api.leadocean.io/v1/account \
-H "x-api-key: $LEADOCEAN_API_KEY" | jq '.data.records | {used, remaining}'Filter on email_status before you send anything. Do not send to invalid, spam_trap, abuse or disposable. Bounces are not refunded.
What you get
The output is a CSV: your original columns plus result, email_status, best_email and best_phone. Rows with no match say not_found and spent nothing. This is the shape of an enriched file, with placeholder values.
first_name,last_name,company,email,linkedin_url,result,email_status,best_email,best_phone
Jane,Doe,Acme,jane.doe@acme.com,,matched,verified,jane.doe@acme.com,+14155550133
John,Roe,Example Corp,,https://www.linkedin.com/in/john-roe-example,not_found,,,Each enrich call returns this response shape from the API reference, with placeholder values. The contact_emails and contact_phones arrays appear only when reveal_email is true.
{
"success": true,
"data": {
"profile_data": { "...": "name, headline, current role, company" },
"contact_data": {
"has_email": true,
"has_phone": true,
"email_status": "verified",
"contact_emails": [
{ "email": "jane.doe@acme.com", "type": "work", "status": "verified" }
],
"contact_phones": [
{ "phone": "+14155550133", "type": "mobile", "priority": 1 }
]
}
},
"meta": { "source": "leadocean", "key_hash": "<cache key>" }
}We read the status list from the free leadocean_list_enum_values tool on 2026-10-21 (description and labels trimmed).
{
"name": "email_status",
"total": 13,
"values": ["verified","catch_all_valid","catch_all","risky","unknown","untested",
"invalid","role","disposable","spam_trap","abuse","derived","none"]
}On a work address, verified means our own verifier sent to the address and it accepted. A search row can show verified more loosely, so trust the enriched status.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key. | Copy the key again from app.leadocean.io and re-export $LEADOCEAN_API_KEY. |
| 402 | The free plan's 1,000 records are spent. They do not reset. | Check GET /v1/account. Move to Solo ($97 a month) or Agency ($497 a month). See pricing. |
| 429 | Above your plan's rate per account (5 a second on Free, 200 on Agency). | Wait the Retry-After seconds. The script above already does. |
| 404 on a row | We hold nothing for that identifier. | Nothing to fix and no record spent. Try the other identifier. |
| ChatGPT browses the web instead | It chose a built-in tool over the plugin. | Start the prompt with "Use the LeadOcean plugin only" and name leadocean_get_lead. |
| Rejected filter in chat | A value is not in the catalogue, such as a guessed status. | Have ChatGPT call leadocean_list_enum_values and use the exact string. |
FAQ
How many records does a CSV enrichment cost?
One per row we match. A row we hold nothing for is a 404 and costs nothing. A 200-row file spends at most 200 records. The script's final count is the real number.
Can I enrich with only a name and a company?
No. Enrich needs one of linkedin_url, email, phone or person_id. With a name and a domain, search by domain first with /v1/people/search, then enrich the person by person_id. Search spends one record per person returned.
Should I use chat or the script?
Use chat for a batch of a few dozen rows, where you want to read the result. Use the script for anything larger. The script runs at API speed, and it does not need ChatGPT.
Does the free plan work for this?
Enrich your first 25 contacts in ChatGPT, free
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →