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_jsonandcsv. - 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.
export LEADOCEAN_API_KEY="paste-your-key-here"
cargo new paginate && cd paginate
cargo add reqwest --features blocking,json
cargo add serde_json csvStep 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.
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.
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
cargo run --release
wc -l people.csvEach 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).
{
"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):
{
"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
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Check $LEADOCEAN_API_KEY is set in the shell that runs cargo run. |
| 429 | Over the plan's rate (5 a second on Free, 200 on Agency) | Honor Retry-After. Slow the loop or upgrade the plan. |
| 402 | The free 1,000 records are spent. They are one-off | Move to a paid plan. Paid plans slow down at their fair-use pace instead of answering 402. |
| 400 Invalid cursor | Cursor edited, stored or reused on a different search | Pass meta.nextCursor unchanged. Restart the search to get a fresh one. |
Empty data | Filter value does not match an enum exactly | Check values with GET /v1/enums or leadocean_list_enum_values. |
No nextCursor, depthCapped true | The walk reached 10,000 rows | Narrow 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 →