Enrichment API

Email to LinkedIn API

GET /v2/people/reverse. One email in, the person who holds it out: LinkedIn URL, name, title, company. 1 record per call, a 404 is free.

Get your free API key →Free to start. No credit card. 1,000 records to spend whenever you like.

GET /v2/people/reverse takes one email address and returns the person who holds it: LinkedIn URL, name, job title and company. It costs 1 record per call. A 404, where nobody holds the address, costs nothing.

This page is the developer reference. For what the data is and what it will not do, read Email to LinkedIn. Going the other way? See the LinkedIn to Email API. Over MCP, the same lookup is leadocean_get_lead with an email.

Quick start (curl)

Create a free account, copy a key with the enrich scope and export it as LEADOCEAN_API_KEY.

bash
curl -s -G "https://api.leadocean.io/v2/people/reverse" \
  --data-urlencode "email=jane.doe@acme.com" \
  -H "x-api-key: $LEADOCEAN_API_KEY"

The endpoint also answers POST with a JSON body of the same field:

bash
curl -s -X POST "https://api.leadocean.io/v2/people/reverse" \
  -H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
  -d '{ "email": "jane.doe@acme.com" }'

Node.js (fetch)

js
const url = new URL("https://api.leadocean.io/v2/people/reverse");
url.searchParams.set("email", "jane.doe@acme.com");

const res = await fetch(url, {
  headers: { "x-api-key": process.env.LEADOCEAN_API_KEY },
});

if (res.status === 404) {
  console.log("Nobody holds this address. Not billed.");
} else if (!res.ok) {
  throw new Error(`${res.status} ${(await res.json()).error?.message}`);
} else {
  const { data } = await res.json();
  console.log(data.linkedin_url ?? `No LinkedIn URL. Keep person_id ${data.person_id}`);
}

Python (requests)

python
import os
import requests

r = requests.get(
    "https://api.leadocean.io/v2/people/reverse",
    params={"email": "jane.doe@acme.com"},
    headers={"x-api-key": os.environ["LEADOCEAN_API_KEY"]},
    timeout=10,
)

if r.status_code == 404:
    print("Nobody holds this address. Not billed.")
else:
    r.raise_for_status()
    data = r.json()["data"]
    print(data["linkedin_url"] or f"No LinkedIn URL. Keep person_id {data['person_id']}")

Request

Send exactly one of email or phone, never both. The key needs the enrich scope.

ParameterTypeRequiredMeaning
emailstring (email)One of the twoThe address to look up.
phonestringOne of the twoA number in any common format. Same endpoint, same price.

There is no lookup by name and company. Search by company domain first, then enrich by person_id. For a work email on a person you already hold, the forward direction is GET /v2/people/email/work.

Response

Response shape from the API reference. Every value below is a placeholder.

json
{
  "success": true,
  "data": {
    "person_id": "1234567",
    "person_group_id": "1234567",
    "linkedin_url": "https://www.linkedin.com/in/jane-doe",
    "full_name": "Jane Doe",
    "job_title": "Finance Manager",
    "company_name": "Acme",
    "company_domain": "acme.com",
    "query": { "email": "jane.doe@acme.com" },
    "matched": {
      "email": "jane.doe@acme.com",
      "status": "verified",
      "verified_batch_date": "2026-09-14",
      "type": "work"
    },
    "matched_in_record": true
  },
  "meta": {
    "source": "leadocean",
    "key_hash": "...",
    "schema": "leadocean.contact.reverse.v2",
    "stability": "stable",
    "credits": 1
  }
}
FieldMeaning
data.linkedin_urlThe profile URL, or null. About one person in five has none.
data.person_idThe record. Send it back to enrich for the full profile.
data.person_group_idThe person across duplicate records. Compare it, never look it up.
data.full_name, job_title, company_name, company_domainThe identity block. Each can be null.
data.queryThe address you sent, as we normalised it.
data.matchedThe address as the record holds it, with type, status and check date. null when the record does not echo it back.
data.matched_in_recordtrue when the record lists the exact address. false still means a person was found.
meta.creditsRecords counted for this call.

This is the thin identity, not the full profile. For the rest, call POST /v1/people/enrich, which costs the same and reads the same cached record. A null matched never means nobody was found: no person is a 404.

Errors

Every failure is { "success": false, "error": { "message": "...", "details": ... } }. Status codes are from the limits docs, September 2026.

StatusMeaningWhat to do
400A field failed validation.Read details. Send one of email or phone. Not billed.
401Missing, wrong or revoked key.Check the x-api-key header.
402Free plan: the 1,000 records are spent. They do not reset.Upgrade. Not billed.
403The key lacks the enrich scope, or the account is suspended.Create a key with the scope.
404No person holds this contact point.Stop. It costs nothing.
429Over 100 requests a second, or 1 a minute on a paid account past its records.Wait for Retry-After, then retry. Not billed.
503The store behind the lookup is unreachable (code: no_source).Retry with backoff. Never billed.

Rate limits and cost

One call is 1 record, including a 200 that comes back with empty fields. A 404, 400, 401, 402, 403, 429 or 503 costs nothing.

The limit is 100 requests per second per key, on every plan. Free is 1,000 records, one-off, no card. Pro is $499 a month, flat, with no per-record price inside fair use. See pricing.

Past your records on Pro you are paced to one request a minute, not cut off. This door does answer with meta, so a throttled reply carries meta.notice and the X-Quota-State: over-limit header. Read them instead of retrying in a loop.

Size first. A people search with hasEmail=true, count=true and limit=1 returns a free total in meta.total, capped at 100,000.

Bulk

The docs describe no batch form of this endpoint, so a list of addresses is one call each, at up to 100 a second per key. POST /v1/exports helps when you start from a segment, not an address list.

An export row is 1 record whatever the columns, and the docs call bulk the cheaper way to enrich. It takes up to 50,000 rows and needs the search scope, plus enrich for email columns. It has no input for a list of emails. Ask for the LinkedIn URL as profile_url.

Email columns are numbered: email_N_address and email_N_status come back as email_1_address, email_1_status, email_2_address and so on. caps sets how many (default 3).

bash
curl -X POST https://api.leadocean.io/v1/exports \
  -H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
  -d '{
    "name": "Finance managers with an email",
    "filters": { "title": ["Finance Manager"], "country": ["US"], "hasEmail": true },
    "limit": 5000,
    "columns": ["person_id", "profile_url", "profile_full_name", "email_N_address", "email_N_status"],
    "caps": { "emails": 2 }
  }'

The reply is a 202 with a job id. Poll GET /v1/exports/{id}, then fetch /v1/exports/{id}/download. Prefer clicks? The Exports page at app.leadocean.io builds the same file.

FAQ

Does a call that finds nobody cost a record?

No. A 404 means nobody holds the address, and it costs nothing. A 200 with empty fields still costs 1 record, because the lookup ran.

Why is linkedin_url null on a match?

About one person in five has no LinkedIn URL on record. Keep the person_id and send it to the enrich endpoint for everything else we hold. Do not treat a null URL as a miss.

Can an agent call it without an API key?

Yes. The MCP server at https://api.leadocean.io/mcp signs in with OAuth 2.1, so no key is pasted. Use leadocean_get_lead with email: 1 record, and a miss is free.

Get your email to LinkedIn API key free

Free to start. No credit card. 1,000 records to spend whenever you like.

Get your free API key →