Pre-Black Friday: 20% off, for as long as you stayEARLYBFon Solo and AgencyEnds Nov 28Start free

How-to guide

How to Page Through a People Search (cURL and Bash)

One search call returns up to 100 people. This script follows the cursor, backs off on a 429 and writes every row to a file.

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

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 and limit up to 100. Page with cursor.
  • 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-After header, 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, jq and 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.

bash
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 .meta

meta.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.

bash
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.json

Every 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.

bash
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.

bash
#!/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.

bash
jq -r '[.profile_data.profile_full_name, .profile_data.profile_headline] | @csv' people.ndjson > people.csv

These 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:

json
{
  "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:

json
{
  "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

ErrorCauseFix
401Missing or wrong x-api-keyCheck echo ${#LEADOCEAN_API_KEY} is not 0. Copy a fresh key from the app.
429Requests above the plan's rate limit (5 a second on Free, 200 on Agency)Wait Retry-After seconds and resend. The script already does.
402Records spentThe free plan has 1,000 for life. Move to a paid plan on the pricing page.
Empty data or a 400A bad enum value, such as VP spelled vp in jobLevelList the exact values with leadocean_list_enum_values or GET /v1/enums.
No nextCursor before you expectedThe search ran out of matches, or meta.depthCapped is true at 10,000 rowsNarrow the filters and run smaller searches.
400 on the bodycount=true sent in the JSON bodySend it as a query parameter. The body rejects unknown fields.
400 "Invalid cursor"The cursor was edited, truncated or came from a different searchPass 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 →