You export 50,000 leads from Node.js with one POST /v1/exports, a polling loop and a file download. The API runs the job in the background, so you never page through results yourself.
TL;DR
- The whole script is about 50 lines of Node 18+ with no npm packages. It uses the built-in
fetch. - Setup takes about 15 minutes. The export runs asynchronously and tells you how long to wait between polls (
pollAfter). - It writes up to 50,000 rows. Each row counts 1 record, or 2 when the columns need an enrichment, as the
fullpreset does (perRowin the 202 response says which). - Bulk CSV export is not on Free. On Solo ($97 a month) a 50,000-row export with contact columns passes the pace of 10k enrichments every 5 hours and slows down. Agency ($497 a month) fits this size.
Prerequisites
- Node.js 18 or newer (
node --version). Save the script asexport.mjsso top-levelawaitworks. - A LeadOcean API 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 export will fill.
Step 1: Size the list for free
Counting spends no records. Run it before you reserve 50,000.
curl -s "https://api.leadocean.io/v1/people/search?count=true&limit=1" \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "Content-Type: application/json" \
-d '{"jobLevel":["VP"],"jobFunction":["Sales & Business Development"],"country":["US"]}'meta.total is capped at 100,000. The MCP tool leadocean_count_leads does the same job. Enum values are exact strings, so check them with GET /v1/enums before you guess.
Step 2: Create the export with retries on 429
A 429 means you sent requests faster than your plan allows: 5 a second on Free, 50 on Solo, 200 on Agency. The response carries Retry-After. The helper below honors it and gives up after 5 tries.
Create export.mjs:
import { createWriteStream } from "node:fs";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
const key = process.env.LEADOCEAN_API_KEY;
if (!key) throw new Error("Set LEADOCEAN_API_KEY");
const base = "https://api.leadocean.io";
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
async function call(path, init = {}) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(base + path, {
...init,
headers: { "x-api-key": key, "Content-Type": "application/json" },
});
if (res.status !== 429 || attempt === 5) return res;
await sleep(Number(res.headers.get("retry-after")) || 2 ** attempt);
}
}
const create = await call("/v1/exports", {
method: "POST",
body: JSON.stringify({
name: "vp-sales-us",
filters: {
jobLevel: ["VP"],
jobFunction: ["Sales & Business Development"],
country: ["US"],
},
limit: 50000,
preset: "full",
}),
});
if (!create.ok) throw new Error(`${create.status} ${await create.text()}`);The filters object takes any people-search filter except cursor, limit, count and facets. The full preset has 45 columns and needs the enrich scope. The search preset has 27 columns and no contact points. perRow in the 202 response says what each row counts: 1, or 2 when the columns need an enrichment.
Step 3: Poll until the job is done
The create call answers 202 with the export object. Read pollAfter, wait that many seconds, then GET /v1/exports/{id} until status leaves queued and running. One export runs per account at a time, so a second job waits in the queue.
Append to export.mjs:
let { data: exp } = await create.json();
const id = exp.id;
while (["queued", "running"].includes(exp.status)) {
await sleep(exp.pollAfter ?? 5);
const r = await call(`/v1/exports/${id}`);
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
({ data: exp } = await r.json());
console.log(`${exp.status}: ${exp.rows} rows`);
}Step 4: Stream the file to disk
When status is done, download the CSV. The file stays available for 7 days. A 409 means the job is still running and a 410 means the file expired.
Append to export.mjs:
if (exp.status !== "done")
throw new Error(`Export ended as ${exp.status}: ${JSON.stringify(exp.error)}`);
const file = await call(`/v1/exports/${id}/download`);
if (!file.ok) throw new Error(`${file.status} ${await file.text()}`);
await pipeline(Readable.fromWeb(file.body), createWriteStream("leads.csv"));
console.log("Saved leads.csv");Run it with node export.mjs. Rows are deduplicated by person_id, so the file can hold fewer rows than you asked for. You are billed for rows written, and the unused reservation comes back (released).
What you get
A free count for the Step 1 filters on 2026-10-21, from leadocean_count_leads:
{ "total": 129494, "totalIsExact": true, "totalAll": 350517, "distinctCompanies": 58121 }total counts people with a verified, catch-all-valid or catch-all email (the tool's default). totalAll is the same filters with no email condition. Either figure is above 50,000, so the export fills.
The export object, response shape from the API reference, placeholder values:
{
"success": true,
"data": {
"id": "exp_acme123",
"name": "vp-sales-us",
"status": "queued",
"requested": 50000,
"rows": 0,
"billed": 0,
"pollAfter": 5
}
}The CSV headers for numbered blocks come back as email_1_address, email_2_address and phone_1_number.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key. | Check $LEADOCEAN_API_KEY is set in the shell that runs node export.mjs. |
429 with Retry-After | Requests above your plan's rate: 5 a second on Free, 200 on Agency. | Let the retry helper wait. Poll at pollAfter, not in a tight loop. |
429 export_limit_daily or export_limit_monthly | Export ceiling reached (500,000 rows a day). | Do not retry. Read resetsAt in the body and run the next export then. |
| 402 | Fewer records left than the export reserves. | Lower limit, or check the balance with GET /v1/account. |
400 export_limit_rows | limit is above 50,000. | Split the list across several exports with narrower filters. |
| Empty file or 0 rows | A filter value that is not an exact enum string. | Look it up with GET /v1/enums and fix the casing. |
FAQ
Can I export more than 50,000 leads?
Yes, in several exports. Each request is capped at 50,000 rows and each account at 500,000 rows a day. Split the list by country or seniority so the filter sets do not overlap.
Does the export spend records if the list is smaller than my limit?
You are billed for rows written. The reservation is up to limit records at once, and what the job does not write is released when it ends.
Can I run this on the Free plan?
No. Free has 1,000 records for life and no bulk CSV export. Use search or enrich on Free, then move to Solo for exports. See pricing.
Do I need to page through results?
No. The job walks the list on the server. Your script polls one export and downloads one file. For the same recipe in other languages, see the Go, C# and TypeScript versions, or the bulk CSV export overview. If 429s are new to you, read API rate limits and 429 errors.
Export your first lead list from Node.js
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →