A people search returns one page at a time. This script follows meta.nextCursor until the pages run out, waits when the API says 429, and writes every person to a file. Plain PHP with the curl extension, about 40 lines.
TL;DR
- Time: about 10 minutes, most of it getting a key.
- Cost: one LeadOcean record per person returned. The test run below caps at 25 people, so it spends 25 of your 1,000 free records.
- The loop stops on its own: when
nextCursoris gone, or whendepthCappedis true at 10,000 rows.
Prerequisites
- PHP 8.0 or later with the curl extension.
- A LeadOcean API key from app.leadocean.io. The Free plan needs no card.
- The key in your shell as
$LEADOCEAN_API_KEY:export LEADOCEAN_API_KEY=your_key_here.
Step 1: Send one authenticated search
Search is a POST to https://api.leadocean.io/v1/people/search with a flat JSON body. Filters are arrays of strings. Auth is the x-api-key header. The full parameter list is in the search docs.
$body = ['title' => ['Head of Sales'], 'country' => ['US'], 'limit' => 100];limit takes 1 to 100. The default is 25. Pick 100 for real runs: fewer calls, same cost per person.
Step 2: Follow the cursor and retry on 429
The response carries meta.nextCursor. Send it back as cursor in the next body. When it is missing, you are done.
A 429 means you went over your plan's rate limit: 5 requests a second on Free, 50 on Solo, 200 on Agency. The response has a Retry-After header. Sleep that long and send the same request again.
Save this as paginate.php:
<?php
$key = getenv('LEADOCEAN_API_KEY') ?: exit("Set LEADOCEAN_API_KEY\n");
$max = (int)($argv[1] ?? 25);
$out = fopen('people.jsonl', 'w');
function call(string $key, array $body): array {
for ($try = 0; $try < 5; $try++) {
$wait = 1;
$ch = curl_init('https://api.leadocean.io/v1/people/search');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["x-api-key: $key", 'Content-Type: application/json'],
CURLOPT_POSTFIELDS => json_encode($body),
CURLOPT_HEADERFUNCTION => function ($c, $h) use (&$wait) {
if (stripos($h, 'retry-after:') === 0) $wait = max(1, (int)trim(substr($h, 12)));
return strlen($h);
},
]);
$raw = curl_exec($ch);
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($code === 429) { sleep($wait); continue; }
if ($code !== 200) exit("HTTP $code: $raw\n");
return json_decode($raw, true);
}
exit("Still rate limited after 5 tries\n");
}
$body = ['title' => ['Head of Sales'], 'country' => ['US'], 'limit' => min(100, $max)];
$written = 0;
do {
$page = call($key, $body);
foreach ($page['data'] as $person) {
if ($written >= $max) break 2;
fwrite($out, json_encode($person) . "\n");
$written++;
}
$body['cursor'] = $page['meta']['nextCursor'] ?? null;
} while ($body['cursor']);
echo "Wrote $written people\n";The break 2 stops the whole run at your cap, so a test never spends more than you asked for. Raise the cap to page deeper: php paginate.php 5000.
Step 3: Run it and read the file
php paginate.php 25
head -c 600 people.jsonlEach line is one person as JSON. Load the file with file() and json_decode per line, or feed it to any JSON Lines reader. JSON Lines is safer than one big array: a crash at row 4,000 keeps the 4,000 rows already written.
Step 4: Size the list before you page it
Paging a large list spends records fast. Count first with count=true as a query parameter and limit=1. The call is free and returns meta.total, 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"]}'If the total is bigger than you want to spend, tighten the filters. Past 10,000 rows one search stops issuing cursors and meta.depthCapped turns true. The fix is narrower filters and several smaller searches, not more paging.
Step 5: Tune the run for your plan
The script is sequential: one request, then the next. On a paid plan that rarely reaches the rate limit. On Free a fast loop can pass 5 a second, and the Retry-After branch handles it.
If you do run workers, give each one its own filter slice, such as a different country value, so no two walk the same rows. Each worker then follows its own cursor and writes its own file. On Free the ceiling is 5 requests a second per key, so more than one worker gains little. Agency allows 200 a second per account and gives you 5 keys.
Keep the cap argument small while you test. Raise it only after the first 25 rows look right.
What you get
The first page of a successful run returns this shape. It is the response shape from the API reference, with placeholder values.
{
"success": true,
"data": [
{ "profile_data": { "...": "fields for Jane Doe at Acme" }, "meta": { "...": "..." } }
],
"meta": {
"count": 25,
"limit": 25,
"nextCursor": "opaque-cursor-string"
}
}Search returns a thin projection of each person with no contact points. To get an email or phone, enrich the person afterwards with POST /v1/people/enrich. The script prints Wrote 25 people when it finishes.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key. | Check echo $LEADOCEAN_API_KEY and copy the key again from the app. |
| 429 | Over the plan's rate limit: 5 a second on Free, 200 on Agency. | The script sleeps for Retry-After and retries. Add a short usleep between pages if it triggers often. |
| 402 | Free plan only: the 1,000 records are spent. Paid plans slow down instead. | Free has 1,000 records for life. Move to a paid plan or lower the cap. |
Empty data or a 400 | A filter value does not match. Enum values are exact strings. | List valid values with GET /v1/enums or the leadocean_list_enum_values MCP tool. |
| 400 "Invalid cursor" | The cursor was edited, truncated or came from a different search. | Pass meta.nextCursor back unchanged with the same body. |
No nextCursor before you expected | The list ended, or meta.depthCapped is true at 10,000 rows. | Check depthCapped. If true, narrow the filters and run again. |
FAQ
Can I use GET instead of POST?
Yes. GET /v1/people/search takes the same filters as comma-separated query parameters. POST with a JSON body is easier once a filter has more than a few values.
Does the cursor expire?
Treat it as single-use. Cursors are signed: an edited or truncated one, or one from a different search, gets 400 "Invalid cursor". Page in one run and restart the query rather than storing a cursor.
How do I get emails and phones for these people?
Search returns no contact points. Enrich each person by person_id with POST /v1/people/enrich, or use the work email and phone endpoints. If you need bulk contact data, POST /v1/exports takes up to 50,000 rows a request.
Is there a Guzzle or Laravel version?
The same loop works with any HTTP client: send the JSON body, read meta.nextCursor, honour Retry-After. Curl keeps this copy-paste script free of dependencies.
Same recipe in another language?
See Go, C# and TypeScript. Pricing for paged runs is on the pricing page.
Page through your first people search free
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →