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 (Java)

One POST starts the export, a short poll waits for it, and one GET writes the file. About 50 lines of Java 17, no dependencies.

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

TL;DR

  • POST /v1/exports takes up to 50,000 rows a request. Java 17's built-in HttpClient is 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 full preset does (perRow in 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 search and enrich scopes, exported as $LEADOCEAN_API_KEY. The default full column preset includes emails and phones, so it needs enrich.
  • 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=1 is 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.

java
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.

java
  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.

java
    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.

java
    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:

json
{
  "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

ErrorCauseFix
401Missing or wrong x-api-key, or $LEADOCEAN_API_KEY is unset in this shellCopy the key from the app, export it again and rerun
403Contact columns were requested and the key lacks the enrich scopeCreate a key with both scopes, or use "preset":"search"
429Above your plan's rate limit (5 a second on Free, 200 on Agency), or export_limit_daily / export_limit_monthlyFor the rate limit, wait the Retry-After seconds, which the helper does. For a daily ceiling, read resetsAt and run the next slice then
402Fewer records left than the export needsLower limit to the remaining value in the body
400 export_limit_rowslimit is above 50,000Send 50,000 or fewer and split the audience into slices
0 rows writtenA filter value is not an exact enum string, such as vp instead of VPList 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 →