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

How-to guide

How to Export 50,000 Leads to CSV (Node.js)

One POST starts the job, one loop polls it, one GET streams the file. Here is the whole script.

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

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 full preset does (perRow in 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 as export.mjs so top-level await works.
  • 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.

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

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

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

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

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

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

ErrorCauseFix
401Missing or wrong x-api-key.Check $LEADOCEAN_API_KEY is set in the shell that runs node export.mjs.
429 with Retry-AfterRequests 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_monthlyExport ceiling reached (500,000 rows a day).Do not retry. Read resetsAt in the body and run the next export then.
402Fewer records left than the export reserves.Lower limit, or check the balance with GET /v1/account.
400 export_limit_rowslimit is above 50,000.Split the list across several exports with narrower filters.
Empty file or 0 rowsA 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 →