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 (cURL and bash)

One POST queues the export, one loop polls it, one GET writes the file. Copy the script, change the filters, run it.

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

TL;DR

  • POST /v1/exports queues an export of up to 50,000 rows. You poll GET /v1/exports/{id} until it is done, then download the CSV.
  • Setup takes about 15 minutes. The run time depends on the queue and the row count (both are estimates).
  • 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.

The script below is 37 lines of bash. It retries on 429, reads Retry-After, and writes leads.csv. If you prefer a typed client, see the Go, C# or TypeScript versions. The full picture is in the bulk CSV export hub.

Prerequisites

  • A LeadOcean account on Solo or Agency. Bulk CSV export is not on Free.
  • An API key with the search and enrich scopes, exported as $LEADOCEAN_API_KEY. The default full column preset reveals emails and phones, so it needs enrich.
  • curl and jq, plus bash 4 or later.

Read the export endpoints in the API reference once (checked October 2026).

Step 1: Size the list for free

A count costs nothing. Send count=true as a query parameter with limit=1, and the body as flat JSON with array filters.

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 '{"jobLevel":["VP"],"country":["US"]}' | jq .meta.total

meta.total is capped at 100,000. If it is under your target, the export writes fewer rows than you asked for. Enum values are exact strings, so VP works and vp does not.

Step 2: Save the script

Save this as export.sh. It runs one request helper, then create, poll and download.

bash
#!/usr/bin/env bash
set -euo pipefail
API=https://api.leadocean.io
TMP=$(mktemp -d)

call() { # call METHOD PATH [JSON]: prints the body, retries on 429
  local tries=0 code wait
  while :; do
    code=$(curl -s -o "$TMP/body" -D "$TMP/head" -w '%{http_code}' -X "$1" "$API$2" \
      -H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" ${3:+-d "$3"})
    if [ "$code" = 429 ] && [ "$tries" -lt 5 ]; then
      wait=$(awk 'tolower($1)=="retry-after:"{print $2+0}' "$TMP/head")
      [ "${wait:-2}" -le 120 ] || { echo "429, retry in ${wait}s. Stop here." >&2; exit 1; }
      sleep "${wait:-2}"; tries=$((tries+1)); continue
    fi
    [ "$code" -lt 300 ] || { echo "HTTP $code: $(cat "$TMP/body")" >&2; exit 1; }
    cat "$TMP/body"; return
  done
}

BODY='{"name":"vp-us-50k","limit":50000,"filters":{"jobLevel":["VP"],"country":["US"]}}'
ID=$(call POST /v1/exports "$BODY" | jq -r .data.id)
echo "queued $ID"

while :; do
  EXP=$(call GET "/v1/exports/$ID")
  STATUS=$(jq -r .data.status <<<"$EXP")
  echo "$STATUS rows=$(jq -r .data.rows <<<"$EXP")"
  case $STATUS in
    done) break ;;
    failed|cancelled) jq .data.error <<<"$EXP"; exit 1 ;;
  esac
  sleep "$(jq -r '.data.pollAfter // 10' <<<"$EXP")"
done

call GET "/v1/exports/$ID/download" > leads.csv
wc -l leads.csv

The 429 branch reads Retry-After and waits. It gives up after 5 tries, or if the wait is over 2 minutes. A long wait usually means the daily export ceiling, which a retry will not fix.

Step 3: Run it

bash
export LEADOCEAN_API_KEY="paste-your-key-here"
chmod +x export.sh
./export.sh

One export runs per account at a time. Others queue. pollAfter tells the loop how long to sleep, so you do not hammer the endpoint. On Free the key allows 5 requests a second, on Solo 50, on Agency 200. A polling loop uses far less than that.

Step 4: Page through a bigger list

One export holds 50,000 rows and one day holds 500,000. For a larger audience, split it by filter and run the script once per slice. Change country, jobFunction or employeeRange in BODY so the slices do not overlap, then merge the files.

bash
head -n 1 leads_1.csv > all.csv
for f in leads_*.csv; do tail -n +2 "$f" >> all.csv; done

Rows inside one export are deduplicated by person_id. Across files, dedupe on that column yourself.

What you get

./export.sh prints the queue and poll lines, then the line count. Output shape, with placeholder values:

text
queued exp_acme123
queued rows=0
running rows=18420
running rows=41000
done rows=50000
50001 leads.csv

The create call answers 202. This is the response shape from the API reference, with placeholder values:

json
{
  "success": true,
  "data": {
    "id": "exp_acme123",
    "name": "vp-us-50k",
    "status": "queued",
    "requested": 50000,
    "perRow": 2,
    "remaining": 120000,
    "pollAfter": 10
  }
}

The CSV has a header row, then one person per row. Numbered columns come back as email_1_address, email_1_status, phone_1_number and so on. Check email_1_status before you send anything. LeadOcean does not refund bounces. The file is kept for 7 days.

Troubleshooting

ErrorCauseFix
401Missing or wrong x-api-keyCheck $LEADOCEAN_API_KEY is set in this shell and the key is not revoked
403Contact columns asked without the enrich scopeCreate a key with both search and enrich, or set "preset":"search"
402Fewer records left than the export needsLower limit to the remaining value in the error
429Above your plan's rate limit (5 a second on Free, 200 on Agency), or the daily export ceilingWait for Retry-After. For the daily ceiling, wait for resetsAt
400limit above 50,000, or a filter an export cannot applySet limit to 50,000 or less. Read the export_filter_unsupported message
Rows below targetThe filter matches fewer people than limitRun the free count in Step 1 and loosen the filters

FAQ

How many rows can one cURL export return?

50,000 rows per request and 500,000 per day per account. The create call reserves records up front and gives back what it does not write.

Does a count cost records?

No. A search with count=true and limit=1 is free, as is GET /v1/exports/columns. Use them before every export.

Can I pick the columns?

Yes. Pass columns as a list of ids from GET /v1/exports/columns, or a preset of search or full. The create response shows perRow: 1 record a row, or 2 when the columns need an enrichment.

How long is the download link valid?

The file stays for 7 days. downloadUrl is a signed link that works without a key for 24 hours, and reading the export again mints a fresh one for free.

Do I need the app instead?

Not if you want a script. The Exports page in app.leadocean.io does the same job with a filter builder, a column picker and the record price shown before you start. Plans are on the pricing page.

Run your first export from the command line

Free to start. No credit card. 1,000 records to spend whenever you like.

Get your free API key →