You send a domain to POST /v1/companies/enrich and get one company record back. This page gives you a Rust program that does that for a list of domains.
TL;DR
- It takes about 10 minutes and uses
reqwest(blocking) andserde_json. - It spends one LeadOcean record per company returned. A 404 (domain not in our data) is free, so the Free plan's 1,000 records cover 1,000 hits.
- It retries on 429 using the
Retry-Afterheader and writes one JSON line per company tocompanies.jsonl.
Prerequisites
- Rust 1.70 or newer with Cargo.
- A LeadOcean API key, exported as
$LEADOCEAN_API_KEY. The Free plan gives one key, no card. - A file
domains.txtwith one domain per line, for exampleacme.com.
Step 1: Send one request
The endpoint takes a flat JSON body with domain or linkedin_url. Auth is the x-api-key header. Test it with curl before writing Rust.
curl -s https://api.leadocean.io/v1/companies/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"domain":"acme.com"}'A hit returns 200 with a data object. A miss returns 404.
Step 2: Add the crates
Create a project and add the two dependencies. The blocking and json features are what the program uses.
cargo new company-lookup && cd company-lookup
cargo add reqwest --features blocking,json
cargo add serde_jsonStep 3: Write the program
Replace src/main.rs with this. It reads domains.txt, calls the API once per domain and writes JSON Lines to companies.jsonl.
use reqwest::{blocking::Client, StatusCode};
use serde_json::{json, Value};
use std::{env, fs, io::Write, thread, time::Duration};
fn lookup(c: &Client, key: &str, domain: &str) -> Result<Option<Value>, String> {
for _ in 0..5 {
let res = c
.post("https://api.leadocean.io/v1/companies/enrich")
.header("x-api-key", key)
.json(&json!({ "domain": domain }))
.send()
.map_err(|e| e.to_string())?;
match res.status() {
StatusCode::OK => return Ok(Some(res.json().map_err(|e| e.to_string())?)),
StatusCode::NOT_FOUND => return Ok(None),
StatusCode::TOO_MANY_REQUESTS => {
let wait = res
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse().ok())
.unwrap_or(1);
thread::sleep(Duration::from_secs(wait));
}
s => return Err(format!("{domain}: HTTP {s} {}", res.text().unwrap_or_default())),
}
}
Err(format!("{domain}: still rate limited"))
}
fn main() -> Result<(), String> {
let key = env::var("LEADOCEAN_API_KEY").map_err(|e| e.to_string())?;
let list = fs::read_to_string("domains.txt").map_err(|e| e.to_string())?;
let mut out = fs::File::create("companies.jsonl").map_err(|e| e.to_string())?;
let client = Client::new();
for domain in list.lines().map(str::trim).filter(|d| !d.is_empty()) {
if let Some(v) = lookup(&client, &key, domain)? {
writeln!(out, "{}", v["data"]).map_err(|e| e.to_string())?;
}
}
Ok(())
}The 429 branch waits for the number of seconds in Retry-After, then tries again, up to five times. A 429 costs no records. serde_json prints each data object on one line, so the output is valid JSON Lines.
Step 4: Run it and save the results
printf 'acme.com\nexample.org\n' > domains.txt
cargo run --release
wc -l companies.jsonlEach line is one company. Misses are skipped, so the line count can be lower than the domain count. The loop is sequential on purpose: it stays under the Free plan's 5 requests a second without a rate limiter.
Step 5: Page through a search when you do not have the domains yet
Enrich returns one company per call, so there is nothing to page. If you need to build the domain list first, call POST /v1/companies/search. The body is flat JSON and filters are arrays (hqCountry is the head office country). limit runs 1 to 100, and you pass the cursor from the previous page to get the next one.
{ "hqCountry": ["US"], "limit": 100, "cursor": "<meta.nextCursor from the last page>" }One search pages to 10,000 rows. The last page has meta.depthCapped true and no nextCursor. Narrow the filters instead of walking further. To size the list first, call GET /v1/companies/count, which is free. Search spends one record per company returned, so check the count before you page.
What you get
This is the response shape from the API reference, with placeholder values. A hit is wrapped as success, data and meta, and data holds these groups.
{
"success": true,
"data": {
"company_data": { "...": "Acme" },
"company_detected_technologies": { "...": "..." },
"company_metrics": { "...": "..." },
"meta": { "...": "..." }
},
"meta": { "...": "..." }
}Source: api.leadocean.io/openapi.json, September 2026. The program writes the inner data object, so each line of companies.jsonl starts with those groups. Check the live reference for the field names inside each group.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key | Check that $LEADOCEAN_API_KEY is exported in the same shell as cargo run |
| 429 | Requests above the plan's rate (5 a second on Free, 200 on Agency) | Keep the Retry-After sleep, or upgrade the plan |
| 402 | The 1,000 Free records are spent | Add a paid plan at leadocean.io/pricing. Solo is $97 a month |
| 400 | A body field the API does not know, such as country on company search | Read error.details, which names the field. Company search uses hqCountry |
| 404 | We hold nothing for that domain | Expected and free. The program skips it |
Empty companies.jsonl | domains.txt has the wrong path or only misses | Run from the project folder that holds domains.txt. Try a bare domain, not a URL |
| Search stops at 10,000 rows | The depth cap on one search | Split the query with narrower filters |
FAQ
Do I need an SDK?
No. The API is plain HTTPS and JSON, so reqwest and serde_json are enough.
Can I look up by LinkedIn URL instead?
Yes. Send linkedin_url in place of domain in the same body.
Can I use async instead of blocking?
Yes. Swap in reqwest::Client and tokio::time::sleep, and keep the same status handling. Cap concurrency at your plan's rate: 5 a second on Free, 50 on Solo, 200 on Agency.
Does a miss cost a record?
No. A 404 is free. A record is spent only for a company you receive.
Where do I see the other languages?
Same recipe in Go, C# and TypeScript. More guides are in the blog. Plan details are on pricing.
Run your first Rust lookup on the free plan
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →