Start the export with POST /v1/exports, poll it until it is done, then write the CSV from downloadUrl to disk. The whole job is one PHP script that uses only the cURL and JSON extensions.
TL;DR
- One export is up to 50,000 rows. The account ceiling is 500,000 rows a day.
- It takes about 15 minutes to set up. The export runs on a worker, so you poll while it builds.
- It spends up to 50,000 records with the
searchpreset, one per row written. Bulk CSV export is not on Free, so you need Solo ($97 a month) or Agency ($497 a month).
Prerequisites
- PHP 8.0 or newer with the
curlandjsonextensions. No Composer packages. - A LeadOcean key on a paid plan, exported as
$LEADOCEAN_API_KEY. Get one at app.leadocean.io. - A filter set. Size it first with the free count call so you know the list is at least as big as you ask for.
Step 1: Size the list for free
Counting costs nothing, so do it before you reserve 50,000 records. The free leadocean_count_leads MCP tool and count=true on search take the same filters as the export.
curl -s "https://api.leadocean.io/v1/people/search?count=true" \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
-d '{"jobLevel":["Director"],"country":["US"],"limit":1}'Filters are arrays of strings and enum values are exact: Director, not director, and US, not United States.
Step 2: Write the export script
This script creates the export, retries on 429, polls until the status is done, and saves the file. Save it as export.php.
<?php
const BASE = 'https://api.leadocean.io';
function call(string $method, string $path, ?array $body = null): array {
for ($try = 0; $try < 6; $try++) {
$retryAfter = 0;
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'x-api-key: ' . getenv('LEADOCEAN_API_KEY'),
'content-type: application/json',
],
CURLOPT_HEADERFUNCTION => function ($ch, $line) use (&$retryAfter) {
if (stripos($line, 'retry-after:') === 0) $retryAfter = (int) substr($line, 12);
return strlen($line);
},
]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$raw = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($status !== 429) return [$status, json_decode((string) $raw, true)];
sleep(max($retryAfter, 1 << $try));
}
throw new RuntimeException('still 429 after 6 tries');
}
[$status, $out] = call('POST', '/v1/exports', [
'name' => 'directors-us',
'limit' => 50000,
'preset' => 'search',
'filters' => ['jobLevel' => ['Director'], 'country' => ['US']],
]);
if ($status !== 202) die("create failed: $status " . json_encode($out) . "\n");
$id = $out['data']['id'];
while (true) {
[, $s] = call('GET', "/v1/exports/$id");
$d = $s['data'];
echo "{$d['status']} {$d['rows']} rows\n";
if ($d['status'] === 'done') {
file_put_contents('leads.csv', fopen($d['downloadUrl'], 'r'));
break;
}
if (in_array($d['status'], ['failed', 'cancelled'], true)) {
die('export ended: ' . json_encode($d['error']) . "\n");
}
sleep(max((int) ($d['pollAfter'] ?? 5), 5));
}The retry reads Retry-After first and falls back to 1, 2, 4 seconds and so on. fopen on downloadUrl needs allow_url_fopen on, which is the PHP default. The signed link works without a key, so the script does not send one.
Step 3: Run it
export LEADOCEAN_API_KEY=your_key_here
php export.phpThe loop prints the status and the rows written so far. When the status is done, the CSV is in leads.csv.
Step 4: Add contact columns when you need them
The search preset has 27 columns and no contact points. The default full preset has 45 columns, including emails 1 to 3 and phones 1 to 2, and it needs the enrich scope. Change the body in the script:
[$status, $out] = call('POST', '/v1/exports', [
'name' => 'directors-us-full',
'limit' => 50000,
'preset' => 'full',
'filters' => ['jobLevel' => ['Director'], 'country' => ['US']],
'caps' => ['emails' => 2, 'phones' => 1],
]);perRow in the 202 response says what each row counts: 1, or 2 when the columns need an enrichment, as full does. Enriched rows also count against the enrichment pace (10k every 5 hours on Solo, 1M a day on Agency). GET /v1/account shows each window.
What you get
The call to POST /v1/exports returns 202 with the export. This is the response shape from the API reference, with placeholder values:
{
"success": true,
"data": {
"id": "exp_acme_0001",
"name": "directors-us",
"status": "queued",
"requested": 50000,
"estimatedTotal": 120000,
"rows": 0,
"billed": 0,
"pollAfter": 5
}
}When the status is done, downloadUrl is set and the file is a plain text/csv download. Rows are deduplicated by person_id, and billed shows the records charged.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key | Check $LEADOCEAN_API_KEY is set in the shell that runs php export.php. getenv returns false if it is not. |
| 429 on requests | Above the plan's rate limit (5 a second on Free, 50 on Solo, 200 on Agency) | Honour Retry-After. The script above does. |
429 export_limit_daily | The account hit 500,000 export rows today | Wait for resetsAt in the error body, or split the job across days. |
| 402 | Fewer records left than the export needs | Lower limit to the remaining value in the error. |
400 export_limit_rows | limit is above 50,000 | Send several exports with narrower filters. |
| Empty or tiny file | A filter value is not an exact enum string | Look up values with leadocean_list_enum_values or GET /v1/enums. |
FAQ
Can I export more than 50,000 rows?
Not in one request. Run several exports with narrower filters, up to 500,000 rows a day. Rows are deduplicated by person_id inside one export, not across exports.
Does the export run on the Free plan?
No. Bulk CSV export is not on Free. Free gives 1,000 records for life, so use search for small pulls. Solo includes exports, and Agency allows 50k rows a file.
How long does the download link last?
The file is kept for 7 days. downloadUrl is a signed link valid for 24 hours that opens without a key, and reading the export again mints a new one at no cost.
Can I use Guzzle or Laravel's HTTP client instead?
Yes. The calls are plain JSON over HTTPS with one header, so any client works. Keep the same three pieces: a retry that honours Retry-After, a poll loop that stops on failed or cancelled, and a download of downloadUrl.
Where is the full reference?
See the export endpoints in the API reference. Related guides: CSV export in Go, in C#, in TypeScript and pricing. Back to the bulk CSV export overview.
Export your first 50,000 leads from PHP
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →