TL;DR
- LeadOcean takes no X handle. You read the name and employer off the profile, find the person with a domain search, then ask for their work email by
person_id. - One profile takes about 15 minutes the first time, mostly getting a key. It spends 2 records: 1 for the search row, 1 for the email lookup.
- For a list, loop the same two calls over a CSV of handles you have already resolved to a name and a company domain. Free runs at 5 requests a second, Agency at 200.
Prerequisites
- A LeadOcean account and key from app.leadocean.io. The free plan has no card and 1,000 records, one-off. Free keys get both the search and enrich scopes.
- The key in your shell as
$LEADOCEAN_API_KEY. curlandjq. For the bulk step, a CSV with one handle, one name and one company domain per line.
export LEADOCEAN_API_KEY="paste-your-key-here"Step 1: Read the profile by hand
Open the profile and write down three things: the display name, the employer, and the link in the bio. This is free and takes two minutes.
An X profile is a poor source for an email. Most bios carry none, so the job is to identify the person, not to scrape an address. The employer and the link in the bio give you a company domain, and the domain is what the search needs.
Pull the domain out of the bio link so you can pass it on cleanly.
echo "https://www.acme.com/about?ref=x" | sed -E 's#https?://(www\.)?([^/?]+).*#\2#'acme.comIf the bio link points to a personal site or a newsletter, find the employer's domain from the profile text instead. Do not guess it.
Step 2: Find the person by company domain
Search people at that domain with the name from the profile. The search body is flat JSON, and filters are arrays. q is free text over name, title and headline, and domain is the company domain.
curl -s -X POST "https://api.leadocean.io/v1/people/search" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "content-type: application/json" \
-d '{
"q": "Jane Doe",
"domain": ["acme.com"],
"limit": 5
}' | jq -c '.data[] | {person_id: .meta.source_ids.person_id}'Each row costs 1 record. Keep limit small so a wrong guess costs little. Check the title and headline against the X bio before you trust a match.
The person_id you need sits at meta.source_ids.person_id on the row. It is a string of digits. Copy it as returned.
You can size a search first for nothing. Send count=true as a query parameter with limit 1, and the response comes back with meta.countOnly true and no rows.
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 '{"q": "Jane Doe", "domain": ["acme.com"], "limit": 1}' | jq '.meta.total'If the total is 0, stop. You have no match for that name at that domain, and you have spent nothing.
Step 3: Get the work email
Send the person_id to the work email endpoint. It returns one address at the person's current employer, or data: null.
curl -s -G "https://api.leadocean.io/v2/people/email/work" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
--data-urlencode "person_id=1234567890" | jqIt only returns addresses whose deliverability was checked (verified), whose mailbox was confirmed on a catch-all domain (catch_all_valid), or whose domain accepts everything (catch_all). So null means "none that qualify", not "this person has no email".
The call costs 1 record, found or not. The pricing page lists what each plan includes.
Step 4: Pull the full record when you need more
Use enrich when you want the profile, every email and the phones in one response. Enrich takes exactly one of linkedin_url, email, phone or person_id. Set reveal_email to true for the contact points.
curl -s -X POST "https://api.leadocean.io/v1/people/enrich" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "content-type: application/json" \
-d '{
"person_id": "1234567890",
"reveal_email": true
}' | jq '.data.contact_data'A person we hold nothing for is a 404 and costs nothing. A found person costs 1 record, and reveal_email adds no extra charge. Since October 2026 the same call also returns career history under resume_data: past roles, education and certifications.
Use /v2/people/email/work when you only want the address to send to. Use enrich when you want the record to store.
Step 5: Run it over a list
Put one line per person in handles.csv with no header: handle, full name, company domain. You resolved the name and domain by hand in Step 1. Loop the search, take the first row, then ask for the work email.
while IFS=, read -r handle name domain; do
pid=$(curl -s -X POST "https://api.leadocean.io/v1/people/search" \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
-d "{\"q\":\"$name\",\"domain\":[\"$domain\"],\"limit\":1}" \
| jq -r '.data[0].meta.source_ids.person_id // empty')
[ -z "$pid" ] && { echo "{\"handle\":\"$handle\",\"email\":null}"; continue; }
curl -s -G "https://api.leadocean.io/v2/people/email/work" \
-H "x-api-key: $LEADOCEAN_API_KEY" --data-urlencode "person_id=$pid" \
| jq -c --arg h "$handle" '{handle: $h, email: .data.email, status: .data.status}'
sleep 0.25
done < handles.csv > results.jsonlRecords add up: 500 handles that all match spend about 1,000 records. Run 20 first and read the hit rate. On Free the loop runs at 5 requests a second at most, so the sleep 0.25 keeps it under the limit.
Bulk CSV export is not on Free. Solo at $97 a month includes it, and Agency at $497 a month adds 200 requests a second. Export takes search filters, not handles, so use POST /v1/exports when you have an audience definition but no list yet. It writes up to 50,000 rows a request and 500,000 a day.
Step 6: Filter on status before you send
An address is not a send list until you have read its status. Keep verified and catch_all_valid. Treat catch_all as a risk you size, not a green light.
jq -c 'select(.email != null and (.status == "verified" or .status == "catch_all_valid"))' \
results.jsonl > sendable.jsonl
wc -l sendable.jsonlLeadOcean gives no refund or credit-back for bounced emails, so this filter is your protection. The dataset is refreshed monthly, and each record carries its own fetched_at date.
What you get
Response shape from the API reference, with placeholder values. The work email call returns success and data, with no meta object.
{
"success": true,
"data": {
"email": "jane.doe@acme.com",
"status": "verified",
"verified_batch_date": "2026-09-20"
}
}The enrich call returns the contact points under data.contact_data. Phones come back as contact_phones[].phone.
{
"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",
"verified_batch_date": "2026-09-20"
}
],
"contact_phones": [
{ "phone": "+14155550133", "type": "mobile" }
]
}
}
}Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key | Re-copy the key from app.leadocean.io and re-export $LEADOCEAN_API_KEY. The header name is x-api-key. |
| 429 | More than your plan's rate limit on one key (5 a second on Free, 200 on Agency) | Honor the Retry-After header, add a short sleep between calls, or run fewer workers. |
| 402 | The free 1,000 records are spent | Check GET /v1/account, then move to Solo at $97 a month or Agency at $497 a month. |
| Search returns no rows | The name and domain do not match one person we hold | Try the name without a middle initial, or drop q and search the domain by title. No records are spent on an empty page. |
data: null on the work email call | No checked address at the current employer qualifies | The call still cost 1 record. Try enrich, or GET /v2/people/email/personal if a personal address suits your use. |
| 400 on a search or export | A filter value outside the catalogue, or an export over the ceiling | Resolve enum values with leadocean_list_enum_values or GET /v1/enums. Exports stop at 50,000 rows a request and 500,000 a day. |
FAQ
Can I find an email from an X handle alone?
Not with LeadOcean. The API takes a LinkedIn URL, an email, a phone or a person_id, not an X handle. Read the name and employer off the profile, then search by company domain.
Does a lookup that finds nothing cost a record?
On the v2 contact endpoints, yes: 1 record per call, including a call that finds nothing. On POST /v1/people/enrich, a person we hold nothing for is a 404 and costs nothing.
Is it free?
The first 1,000 records are free for life, with no card. At 2 records per profile, that is about 500 profiles.
What if I already have their LinkedIn URL?
Skip Steps 1 and 2. See how to find an email from a LinkedIn profile and send the URL straight to the work email endpoint.
Where else can I start from?
The same pattern works from other profiles. See how to find an email from GitHub and from Crunchbase.
Find the work email behind any X profile, free
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →