Send the search once, read meta.nextCursor from the response, and send the same body again with cursor set. Repeat until no cursor comes back. This page gives you a 23-line bash script that does it. It retries on a 429 and writes one JSON row per line.
TL;DR
- Search with
POST /v1/people/search, a flat JSON body andlimitup to 100. Page withcursor. - It takes about 10 minutes and spends 300 records at the default cap of 3 pages. The free plan has 1,000 records for life.
- On a 429, wait for the
Retry-Afterheader, then resend the same request.
Prerequisites
- A LeadOcean API key from app.leadocean.io. The free plan needs no card.
- The key in your shell:
export LEADOCEAN_API_KEY=.... Never paste it into a script. curl,jqand bash. Nothing else.
Step 1: Size the search for free
Check how many people match before you page. A count costs no records. Send count=true as a query parameter, not in the body, with limit=1.
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":["VP Sales"],"country":["US"]}' | jq .metameta.total is capped at 100,000. data comes back empty and meta.countOnly is true. If the total is far above what you need, add filters now.
Step 2: Send the first page
The body is flat JSON. Filters such as title and country are arrays of strings. limit runs from 1 to 100 and defaults to 25.
curl -s -X POST https://api.leadocean.io/v1/people/search \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "content-type: application/json" \
-d '{"title":["VP Sales"],"country":["US"],"limit":100}' > page1.json
jq '.meta' page1.jsonEvery person returned counts one record. Look at meta.nextCursor. If it is missing, you have all the rows.
Step 3: Follow the cursor
Send the identical body plus "cursor" set to the value you just read. Keep the filters and limit the same on every page.
CURSOR=$(jq -r '.meta.nextCursor // empty' page1.json)
curl -s -X POST https://api.leadocean.io/v1/people/search \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "content-type: application/json" \
-d "{\"title\":[\"VP Sales\"],\"country\":[\"US\"],\"limit\":100,\"cursor\":\"$CURSOR\"}" | jq '.meta'One search pages through at most 10,000 rows by default. At that point meta.depthCapped is true and no cursor is issued. Narrow the filters and run several smaller searches instead.
Step 4: Put it in a loop with 429 retries
This script pages up to MAX_PAGES times. It waits out any 429 and appends each row to people.ndjson. The retry reads Retry-After (seconds, per RFC 9110, checked September 2026) and falls back to 1 second.
#!/usr/bin/env bash
set -euo pipefail
API=https://api.leadocean.io/v1/people/search
OUT=people.ndjson
MAX_PAGES=${MAX_PAGES:-3}
cursor=""; : > "$OUT"
for page in $(seq 1 "$MAX_PAGES"); do
body=$(jq -n --arg c "$cursor" '{title:["VP Sales"],country:["US"],limit:100}
+ (if $c=="" then {} else {cursor:$c} end)')
for try in 1 2 3 4 5; do
code=$(curl -s -D hdr.txt -o resp.json -w '%{http_code}' -X POST "$API" \
-H "x-api-key: $LEADOCEAN_API_KEY" -H 'content-type: application/json' -d "$body")
[ "$code" = 429 ] || break
wait=$(awk 'tolower($1)=="retry-after:"{print $2+0}' hdr.txt)
sleep "${wait:-1}"
done
[ "$code" = 200 ] || { echo "HTTP $code" >&2; cat resp.json >&2; exit 1; }
jq -c '.data[]' resp.json >> "$OUT"
cursor=$(jq -r '.meta.nextCursor // empty' resp.json)
[ -n "$cursor" ] || break
done
wc -l "$OUT"Save it as page.sh, then run bash page.sh. Raise the cap with MAX_PAGES=20 bash page.sh when you are ready to spend more records.
Step 5: Turn the file into a CSV
people.ndjson is one JSON object per line. Search returns a thin projection with no contact points, so this gives you names and profile fields. Pick the fields you want from the response shape below.
jq -r '[.profile_data.profile_full_name, .profile_data.profile_headline] | @csv' people.ndjson > people.csvThese field names follow the enrich docs, which say search returns the same person record. Check them against one line of your own output first: head -1 people.ndjson | jq 'keys'.
What you get
The size call in Step 1 is free. Here is a count from the MCP tool leadocean_count_leads. Run on 2026-10-21 for title "VP Sales" in the US:
{
"total": 11957,
"totalIsExact": true,
"totalAll": 40490,
"distinctCompanies": 8502
}total counts people whose best email is verified, catch-all-valid or catch-all, the tool's default. totalAll is the same filters with no email condition.
A search page looks like this.
Response shape from the API reference, placeholder values:
{
"success": true,
"data": [
{ "profile_data": { "profile_full_name": "Jane Doe", "profile_headline": "VP Sales at Acme" },
"contact_data": { "has_email": true, "email_status": "verified" } }
],
"meta": { "count": 100, "limit": 100, "nextCursor": "eyJ...placeholder" }
}Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key | Check echo ${#LEADOCEAN_API_KEY} is not 0. Copy a fresh key from the app. |
| 429 | Requests above the plan's rate limit (5 a second on Free, 200 on Agency) | Wait Retry-After seconds and resend. The script already does. |
| 402 | Records spent | The free plan has 1,000 for life. Move to a paid plan on the pricing page. |
Empty data or a 400 | A bad enum value, such as VP spelled vp in jobLevel | List the exact values with leadocean_list_enum_values or GET /v1/enums. |
No nextCursor before you expected | The search ran out of matches, or meta.depthCapped is true at 10,000 rows | Narrow the filters and run smaller searches. |
| 400 on the body | count=true sent in the JSON body | Send it as a query parameter. The body rejects unknown fields. |
| 400 "Invalid cursor" | The cursor was edited, truncated or came from a different search | Pass meta.nextCursor back unchanged with the same body. |
FAQ
Why does the cursor stop at 10,000 rows?
One search pages to 10,000 rows by default. Past that, meta.depthCapped is true and no cursor is issued. Split the audience with more filters, such as country or seniority, and run each slice on its own.
Can I use GET instead of POST?
Yes. GET /v1/people/search takes the same filters as comma-separated query parameters, with cursor and limit. POST is easier for long arrays.
Does a 429 cost records?
No. A 429 is free, and so are 402 and 503 responses. Only people returned count.
What if I need more than a few thousand rows?
Use a bulk export instead of paging. POST /v1/exports takes up to 50,000 rows a request and 500,000 a day. Bulk CSV export is not on the free plan.
Do the other language guides work the same way?
The loop is identical. See the guides for Go, C# and TypeScript.
Page through your first 1,000 people free
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →