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

How-to guide

How to Page Through a People Search (in Rust)

Auth, cursor paging, 429 retries and a CSV file in one Rust file under 60 lines.

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

You can walk a LeadOcean people search in Rust with one file: send a flat JSON body, read meta.nextCursor, send it back, and stop when it is null. This guide adds the key header, a retry on 429 and a CSV writer.

TL;DR

  • The code is one Rust file of 48 lines. It uses reqwest, serde_json and csv.
  • It takes about 15 minutes, including getting a key and the first compile.
  • The sample run spends 100 LeadOcean records (4 pages of 25 people). The free plan gives you 1,000 records for life.

Prerequisites

  • A LeadOcean account. Start free at app.leadocean.io, no card needed.
  • An API key from the app, exported as $LEADOCEAN_API_KEY.
  • Rust 1.70 or newer with Cargo.
bash
export LEADOCEAN_API_KEY="paste-your-key-here"
cargo new paginate && cd paginate
cargo add reqwest --features blocking,json
cargo add serde_json csv

Step 1: Size the search for free

Count first. A count spends nothing, and it tells you how many pages you are about to buy.

Send count=true as a query parameter, with limit 1. The JSON body rejects unknown fields, so the flag cannot go there. meta.total is capped at 100,000.

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 '{"title":["Head of Sales"],"country":["US"]}'

Step 2: Write the Rust program

The endpoint is POST /v1/people/search on https://api.leadocean.io. The body is flat JSON. Filters such as title and country are arrays of strings. limit runs from 1 to 100.

Replace src/main.rs with this.

rust
use serde_json::{json, Value};
use std::{env, thread::sleep, time::Duration};

fn post(c: &reqwest::blocking::Client, key: &str, body: &Value) -> Result<Value, String> {
    for _ in 0..5 {
        let res = c
            .post("https://api.leadocean.io/v1/people/search")
            .header("x-api-key", key)
            .json(body)
            .send()
            .map_err(|e| e.to_string())?;
        let status = res.status().as_u16();
        if status == 429 {
            let wait = res
                .headers()
                .get("retry-after")
                .and_then(|v| v.to_str().ok())
                .and_then(|v| v.parse::<u64>().ok())
                .unwrap_or(1);
            sleep(Duration::from_secs(wait.max(1)));
            continue;
        }
        if status != 200 {
            return Err(format!("status {status}"));
        }
        return res.json::<Value>().map_err(|e| e.to_string());
    }
    Err("still rate limited".into())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let key = env::var("LEADOCEAN_API_KEY")?;
    let client = reqwest::blocking::Client::new();
    let mut out = csv::Writer::from_path("people.csv")?;
    let mut body = json!({ "title": ["Head of Sales"], "country": ["US"], "limit": 25 });
    for _ in 0..4 {
        let page = post(&client, &key, &body)?;
        for row in page["data"].as_array().unwrap_or(&vec![]) {
            out.write_record([row.to_string()])?;
        }
        match page["meta"]["nextCursor"].as_str() {
            Some(c) => body["cursor"] = json!(c),
            None => break,
        }
    }
    out.flush()?;
    Ok(())
}

Three details matter, and each one is a common way to break a paging loop. Send the cursor back exactly as returned. Cursors are signed, so an edited one gets a 400, and so does one from a different search. And the loop caps at 4 pages so a test run cannot spend more than 100 records.

Step 3: Retry on 429

A 429 means you went over your plan's rate: 5 requests a second on Free, 50 on Solo, 200 on Agency. The reply carries a Retry-After header in seconds (RFC 9110, September 2026).

The post function above reads that header, sleeps, and tries again up to 5 times. A paging loop that runs one request at a time rarely hits the limit on Solo or Agency. It can on Free if you add parallel workers.

Step 4: Run it and check the file

bash
cargo run --release
wc -l people.csv

Each CSV row holds one person as a JSON string. That keeps the sample short, and you lose nothing: every field the search returns is in the row. To get columns, read profile_data and contact_data from each row and write the fields you want.

What you get

The Step 1 count below was run on 2026-10-21 with the LeadOcean MCP tool leadocean_count_leads, which is free. It filtered title "Head of Sales" in the US (trimmed).

json
{
  "total": 6564,
  "totalIsExact": true,
  "totalAll": 16363,
  "distinctCompanies": 4932
}

total counts people with a verified, catch-all-valid or catch-all address. totalAll is the same filter with no email condition. At 25 a page, the 6,564 would take 263 pages, and one search stops at 10,000 rows (meta.depthCapped). Narrow the filters before you walk a list that large.

Each page returns this shape (response shape from the API reference, placeholder values):

json
{
  "success": true,
  "data": [
    { "profile_data": { "...": "..." }, "contact_data": { "has_email": true, "email_status": "verified" } }
  ],
  "meta": { "count": 25, "nextCursor": "opaque-signed-string" }
}

Search returns a thin projection with no contact points. To reveal an email or phone, enrich the person with POST /v1/people/enrich using the person_id, or call GET /v2/people/phone. Each reveal spends records, so filter your CSV first. See pricing for what each plan allows.

Troubleshooting

ErrorCauseFix
401Missing or wrong x-api-key headerCheck $LEADOCEAN_API_KEY is set in the shell that runs cargo run.
429Over the plan's rate (5 a second on Free, 200 on Agency)Honor Retry-After. Slow the loop or upgrade the plan.
402The free 1,000 records are spent. They are one-offMove to a paid plan. Paid plans slow down at their fair-use pace instead of answering 402.
400 Invalid cursorCursor edited, stored or reused on a different searchPass meta.nextCursor unchanged. Restart the search to get a fresh one.
Empty dataFilter value does not match an enum exactlyCheck values with GET /v1/enums or leadocean_list_enum_values.
No nextCursor, depthCapped trueThe walk reached 10,000 rowsNarrow the filters and run smaller searches.

FAQ

Is GET or POST better for search?

Use POST. Filters are arrays, and a JSON body is easier to read than a long query string. The endpoint accepts both.

How many records does paging cost?

One record per person returned. A count, an empty page and a 429 cost nothing. Four pages of 25 is 100 records.

Can I use async Rust?

Yes, but you do not need it here. A cursor is a position in one walk, so each page waits for the last. The blocking client keeps the file short.

Is this the right tool for 100,000 rows?

No. For large lists use POST /v1/exports, which takes up to 50,000 rows a request. Bulk CSV export is not on Free.

Where do I go next?

The same walk is covered in Go, C# and TypeScript. All of them sit under the API tutorials hub, and plans are on pricing.

Run your first Rust people search on the free plan

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

Get your free API key →