TL;DR
POST /v1/exportstakes up to 50,000 rows a request. Java 17's built-inHttpClientis enough: start, poll, download.- The code is about 50 lines. Plan on 10 minutes of your own time, plus the export run (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
fullpreset does (perRowin 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.
LeadOcean exports are asynchronous. The POST is admitted or refused on the spot, a worker builds the file, and you download it when status is done. For a no-code route, the bulk export also lives on the Exports page of the app. The same recipe exists in Go, C# and TypeScript.
Prerequisites
- Java 17 or newer (the code uses a text block). No Maven or Gradle dependencies.
- A LeadOcean API key with the
searchandenrichscopes, exported as$LEADOCEAN_API_KEY. The defaultfullcolumn preset includes emails and phones, so it needsenrich. - A paid plan for the 50,000-row run. Free has 1,000 records and no bulk CSV export. See pricing.
- Filters you have sized first.
GET /v1/people/search?count=true&limit=1is free and tells you how many people match.
Step 1: Add an HTTP helper that retries on 429
Every request carries the x-api-key header. Above your plan's rate limit the API answers 429 with a Retry-After header: 5 requests a second on Free, 50 on Solo, 200 on Agency. The helper waits that long and tries again, up to five times.
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
import java.util.regex.*;
public class Export {
static final String BASE = "https://api.leadocean.io";
static final String KEY = System.getenv("LEADOCEAN_API_KEY");
static final HttpClient HTTP = HttpClient.newHttpClient();
static HttpResponse<String> call(String method, String path, String body) throws Exception {
for (int attempt = 0; ; attempt++) {
var req = HttpRequest.newBuilder(URI.create(BASE + path))
.header("x-api-key", KEY).header("content-type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(body)).build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() != 429 || attempt == 5) return res;
Thread.sleep(1000L * res.headers().firstValue("Retry-After").map(Long::parseLong).orElse(2L));
}
}
static String field(String json, String name) {
Matcher m = Pattern.compile("\"" + name + "\"\\s*:\\s*\"?([^\",}]+)").matcher(json);
return m.find() ? m.group(1) : null;
}The field helper pulls one value out of the JSON so the file needs no JSON library. Swap in Jackson or Gson if you already use one.
Step 2: Start the export
The body takes filters (the same filters as people search, each a flat key with an array of strings), limit (1 to 50,000) and an optional name. Omit columns and the full preset applies. Enum values are exact strings, so check them with GET /v1/enums first.
public static void main(String[] args) throws Exception {
String body = """
{"name":"vp-sales-us","limit":50000,
"filters":{"jobLevel":["VP"],"jobFunction":["Sales & Business Development"],"country":["US"]}}""";
var start = call("POST", "/v1/exports", body);
if (start.statusCode() != 202) throw new RuntimeException(start.statusCode() + " " + start.body());
String id = field(start.body(), "id");
System.out.println("queued " + id + ", perRow " + field(start.body(), "perRow"));A 202 means queued. The API reserves up to limit records at once and bills each row actually written, then gives the rest back. One export runs per account at a time, so a second one queues behind the first.
Step 3: Poll until it is done
GET /v1/exports/{id} returns status (queued, running, done, failed or cancelled) and pollAfter, the seconds to wait before asking again. Polling costs nothing.
String status;
do {
var poll = call("GET", "/v1/exports/" + id, null);
status = field(poll.body(), "status");
String wait = field(poll.body(), "pollAfter");
System.out.println(status + ", rows " + field(poll.body(), "rows"));
if (status.equals("queued") || status.equals("running"))
Thread.sleep(1000L * (wait == null || wait.equals("null") ? 5 : Long.parseLong(wait)));
} while (status.equals("queued") || status.equals("running"));
if (!status.equals("done")) throw new RuntimeException("export " + status);Step 4: Download and write the CSV
GET /v1/exports/{id}/download streams plain text/csv. It answers 409 while the export still runs and 410 once the file has expired. The file is kept for 7 days.
var csv = call("GET", "/v1/exports/" + id + "/download", null);
Files.writeString(Path.of("leads.csv"), csv.body());
System.out.println("wrote leads.csv");
}
}Run it with java Export.java. Past 50,000 rows, slice the audience with filters (by country or job level) and run one export per slice. The day allows 500,000 rows in total.
What you get
The 202 response and the poll response share one shape. This is the response shape from the API reference, with placeholder values:
{
"success": true,
"data": {
"id": "exp_acme_0001",
"name": "vp-sales-us",
"status": "done",
"requested": 50000,
"rows": 50000,
"billed": 50000,
"released": 0,
"pollAfter": null,
"file": { "bytes": 31457280, "expiresAt": "2026-10-28T12:00:00Z" }
}
}The full preset writes 45 columns, among them email_1_address, email_1_status and phone_1_number. The CSV is deduplicated by person_id, so rows can be lower than requested when fewer people match. Rows you do not get are not billed.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key, or $LEADOCEAN_API_KEY is unset in this shell | Copy the key from the app, export it again and rerun |
| 403 | Contact columns were requested and the key lacks the enrich scope | Create a key with both scopes, or use "preset":"search" |
| 429 | Above your plan's rate limit (5 a second on Free, 200 on Agency), or export_limit_daily / export_limit_monthly | For the rate limit, wait the Retry-After seconds, which the helper does. For a daily ceiling, read resetsAt and run the next slice then |
| 402 | Fewer records left than the export needs | Lower limit to the remaining value in the body |
400 export_limit_rows | limit is above 50,000 | Send 50,000 or fewer and split the audience into slices |
| 0 rows written | A filter value is not an exact enum string, such as vp instead of VP | List the allowed values with GET /v1/enums and fix the case |
FAQ
How many rows can one Java call export?
50,000 rows a request and 500,000 rows a day per account. For more, run several exports with different filters and merge the files.
Does the export cost more than search?
Each row counts 1 record, or 2 when the columns need an enrichment, as the full preset does. The reservation at creation can be larger than the final bill, and unused records are released when the export ends. Check perRow in the 202 response.
Can I use it on the Free plan?
No. Bulk CSV export is not on Free. Free gives 1,000 records for life, which you can spend on search and enrich calls with the same key.
Why not page with a cursor?
Search pages with cursor, 100 rows at most, and one search stops at 10,000 rows. An export has no paging on your side: the worker walks the whole set and you download one file.
Where is the full reference?
The request and response schemas are in the LeadOcean API reference, and the ceilings and pace are in records, limits and errors. Plans are on pricing.
Run your first export from Java
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →