You can walk a LeadOcean people search in Python with one script: send a flat JSON body, read meta.nextCursor, send it back, and stop when it is null. This guide adds the key header, a retry on 429 and a CSV writer.
TL;DR
- The script is 29 lines. It needs only the
requestspackage. - It takes about 10 minutes, including getting a key.
- The sample run spends 100 LeadOcean records (4 pages of 25 people). The free plan gives you 1,000 records for life.
Prerequisites
- A LeadOcean account. Start free at app.leadocean.io, no card needed.
- An API key from the app, exported as
$LEADOCEAN_API_KEY. - Python 3.9 or newer and
pip install requests.
export LEADOCEAN_API_KEY="paste-your-key-here"Step 1: Size the search for free
Count first. A count spends nothing, and it tells you how many pages you are about to buy.
Send count=true as a query parameter, with limit 1. The JSON body rejects unknown fields, so the flag cannot go there. meta.total is capped at 100,000.
curl -s -X POST "https://api.leadocean.io/v1/people/search?count=true&limit=1" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"title":["Head of Sales"],"country":["US"]}'Step 2: Write the Python script
The endpoint is POST /v1/people/search on https://api.leadocean.io. The body is flat JSON. Filters such as title and country are arrays of strings. limit runs from 1 to 100.
Save this as search.py.
import csv, json, os, time
import requests
URL = "https://api.leadocean.io/v1/people/search"
HEADERS = {"x-api-key": os.environ["LEADOCEAN_API_KEY"]}
def post(body):
for _ in range(5):
res = requests.post(URL, json=body, headers=HEADERS, timeout=30)
if res.status_code == 429:
time.sleep(int(res.headers.get("Retry-After", 1)))
continue
res.raise_for_status()
return res.json()
raise RuntimeError("still rate limited")
body = {"title": ["Head of Sales"], "country": ["US"], "limit": 25}
with open("people.csv", "w", newline="") as f:
out = csv.writer(f)
for _ in range(4):
page = post(body)
for row in page["data"]:
out.writerow([json.dumps(row)])
cursor = page["meta"].get("nextCursor")
if not cursor:
break
body["cursor"] = cursorThree details matter, and each one is a common way to break a paging loop. Send the cursor back exactly as returned. Cursors are signed, so an edited one gets a 400, and so does one from a different search. And the loop caps at 4 pages so a test run cannot spend more than 100 records.
Step 3: Retry on 429
A 429 means you went over your plan's rate: 5 requests a second on Free, 50 on Solo, 200 on Agency. The reply carries a Retry-After header in seconds (RFC 9110, September 2026).
The post function above reads that header, sleeps, and tries again up to 5 times. A paging loop that runs one request at a time rarely hits the limit on Solo or Agency. It can on Free if you add parallel workers.
Step 4: Run it and check the file
python3 search.py
wc -l people.csvEach CSV row holds one person as a JSON string. That keeps the sample short, and you lose nothing: every field the search returns is in the row. To get columns, read profile_data and contact_data from each row and write the fields you want.
What you get
The Step 1 count below was run on 2026-10-21 with the LeadOcean MCP tool leadocean_count_leads, which is free. It filtered title "Head of Sales" in the US.
{
"total": 6564,
"totalIsExact": true,
"totalAll": 16363,
"distinctCompanies": 4932,
"credits": 0
}total counts people with a verified, catch-all-valid or catch-all address. totalAll is the same filter with no email condition. At 25 a page, 6,564 rows is 263 pages and 16,363 is 655. One search stops at 10,000 rows (meta.depthCapped). Narrow the filters before you walk a list that large.
Each page returns this shape (response shape from the API reference, placeholder values):
{
"success": true,
"data": [
{ "profile_data": { "...": "..." }, "contact_data": { "has_email": true, "email_status": "verified" } }
],
"meta": { "count": 25, "nextCursor": "opaque-signed-string" }
}Search returns a thin projection with no contact points. To reveal an email or phone, enrich the person with POST /v1/people/enrich using the person_id, or call GET /v2/people/phone. Each reveal spends records, so filter your CSV first. See pricing for what each plan allows.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Check $LEADOCEAN_API_KEY is set in the shell that runs python3. |
| 429 | Over the plan's rate (5 a second on Free, 200 on Agency) | Honor Retry-After. Slow the loop or upgrade the plan. |
| 402 | Free plan only: the 1,000 records are spent and do not reset | Move to a paid plan. Paid plans are never refused: past their pace they slow to 1 request a minute (a 429). |
| 400 Invalid cursor | Cursor edited, stored or reused on a different search | Pass meta.nextCursor unchanged. Restart the search to get a fresh one. |
Empty data or a 400 | Filter value does not match an enum exactly | Check values with GET /v1/enums or leadocean_list_enum_values. |
No nextCursor, depthCapped true | The walk reached 10,000 rows | Narrow the filters and run smaller searches. |
FAQ
Is GET or POST better for search?
Use POST. Filters are arrays, and a JSON body is easier to read than a long query string. The endpoint accepts both.
How many records does paging cost?
One record per person returned. A count, an empty page and a 429 cost nothing. Four pages of 25 is 100 records.
Can I run pages in parallel?
No. A cursor is a position in one walk, so each page depends on the last. Run separate searches in parallel, within your rate limit.
Is this the right tool for 100,000 rows?
No. For large lists use POST /v1/exports, which takes up to 50,000 rows a request. Bulk CSV export is not on Free.
Where do I go next?
The same walk is covered in Go, C# and TypeScript. All of them sit under the API tutorials hub, and plans are on pricing.
Run your first Python people search on the free plan
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →