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 (in Rust)

One POST starts the export, one loop polls it, one GET writes the file. About 40 lines of Rust with reqwest and serde_json.

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

Start the export with POST /v1/exports, poll it until it is done, then write the CSV from downloadUrl. The job is one blocking program on reqwest and serde_json.

TL;DR

  • One export is up to 50,000 rows. The account ceiling is 500,000 rows a day.
  • It takes about 20 minutes to set up. The export runs on a worker, so you poll while it builds.
  • It spends up to 50,000 records, one per row written. Bulk CSV export is not on Free, so you need Solo ($97 a month) or Agency ($497 a month).

Prerequisites

  • Rust 1.70 or newer and Cargo.
  • 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 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.

bash
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: Add the crates

Create a project with cargo new leads-export and add two dependencies to Cargo.toml.

toml
[dependencies]
reqwest = { version = "0.12", features = ["blocking", "json"] }
serde_json = "1"

The blocking client keeps the code short. There is no async runtime to set up for a job that mostly waits.

Step 3: Write the export client

This program creates the export, retries on 429, polls until the status is done, and writes the file. Save it as src/main.rs.

rust
use reqwest::{blocking::{Client, Response}, Method, StatusCode};
use serde_json::{json, Value};
use std::{env, fs::File, thread::sleep, time::Duration};

type Res<T> = Result<T, Box<dyn std::error::Error>>;

fn call(c: &Client, m: Method, path: &str, body: Option<&Value>) -> Res<Response> {
    let key = env::var("LEADOCEAN_API_KEY")?;
    for attempt in 0..6u32 {
        let mut req = c.request(m.clone(), format!("https://api.leadocean.io{path}"))
            .header("x-api-key", &key);
        if let Some(b) = body { req = req.json(b); }
        let res = req.send()?;
        if res.status() != StatusCode::TOO_MANY_REQUESTS { return Ok(res); }
        let wait = res.headers().get("retry-after").and_then(|v| v.to_str().ok())
            .and_then(|v| v.parse().ok()).unwrap_or(1u64 << attempt);
        sleep(Duration::from_secs(wait));
    }
    Err("still 429 after 6 tries".into())
}

fn main() -> Res<()> {
    let c = Client::builder().timeout(None).build()?;
    let body = json!({"name": "directors-us", "limit": 50000, "preset": "search",
        "filters": {"jobLevel": ["Director"], "country": ["US"]}});
    let res = call(&c, Method::POST, "/v1/exports", Some(&body))?;
    if res.status() != StatusCode::ACCEPTED {
        return Err(format!("{}: {}", res.status(), res.text()?).into());
    }
    let id = res.json::<Value>()?["data"]["id"].as_str().ok_or("no id")?.to_string();
    loop {
        let s: Value = call(&c, Method::GET, &format!("/v1/exports/{id}"), None)?.json()?;
        let d = &s["data"];
        println!("{} {} rows", d["status"], d["rows"]);
        match d["status"].as_str() {
            Some("done") => {
                let url = d["downloadUrl"].as_str().ok_or("no downloadUrl")?;
                c.get(url).send()?.copy_to(&mut File::create("leads.csv")?)?;
                return Ok(());
            }
            Some("failed") | Some("cancelled") => return Err(format!("export {}", d["status"]).into()),
            _ => sleep(Duration::from_secs(d["pollAfter"].as_u64().unwrap_or(5).max(5))),
        }
    }
}

The retry reads Retry-After first and falls back to 1, 2, 4 seconds and so on. The download goes straight to disk, so 50,000 rows never sit in memory. The failed and cancelled branch stops the loop instead of polling forever. I have not compiled this on your toolchain, so run cargo check first.

Step 4: Run it

bash
export LEADOCEAN_API_KEY=your_key_here
cargo run --release

The loop prints the status and the rows written so far. When the status is done, the CSV is in leads.csv.

Step 5: 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:

rust
let body = json!({"name": "directors-us-full", "limit": 50000, "preset": "full",
    "filters": {"jobLevel": ["Director"], "country": ["US"]},
    "caps": {"emails": 2, "phones": 1}});

Every row costs one record whatever the columns. With the full preset the export reserves up to two records a row while it runs, so check your remaining records first.

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:

json
{
  "success": true,
  "data": {
    "id": "exp_acme_0001",
    "name": "directors-us",
    "status": "queued",
    "requested": 50000,
    "estimatedTotal": 4798296,
    "rows": 0,
    "billed": 0,
    "pollAfter": 5
  }
}

On 2026-10-21 the free leadocean_count_leads tool returned 4,798,296 people for jobLevel Director in country US. That is its default: people whose best email is verified, catch_all_valid or catch_all. With no email condition the same filters match 12,741,708 people. One export takes the first 50,000.

Troubleshooting

ErrorCauseFix
401Missing or wrong x-api-keyCheck $LEADOCEAN_API_KEY is set in the shell that runs cargo run.
429 on requestsAbove the plan's rate limit (5 a second on Free, 50 on Solo, 200 on Agency)Honour Retry-After. The client above does.
429 export_limit_dailyThe account hit 500,000 export rows todayWait for resetsAt in the error body, or split the job across days.
402Fewer records left than the export needsLower limit to the remaining value in the error body, and check GET /v1/account first.
400 export_limit_rowslimit is above 50,000Send several exports with narrower filters.
Empty or tiny fileA filter value is not an exact enum stringLook 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.

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 cancel a running export?

Yes: DELETE /v1/exports/{id}. A queued export never runs. A running one stops at its next checkpoint, and rows already written stay billed and downloadable.

Where is the full reference?

The export endpoints (POST /v1/exports, GET /v1/exports/{id}, GET /v1/exports/{id}/download, DELETE /v1/exports/{id}) are in the API reference. Limits and error codes are in records, limits and errors.

Related guides: CSV export in Go, in C#, in TypeScript and pricing.

Back to the blog hub.

Export your first 50,000 leads from Rust

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

Get your free API key →