You can walk a LeadOcean people search in Go 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 Go file of 66 lines. It uses only the standard library.
- It takes about 10 minutes, including getting a key.
- 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. - Go 1.21 or newer. No third-party packages.
export LEADOCEAN_API_KEY="paste-your-key-here"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.
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 Go 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.
Save this as main.go.
package main
import (
"bytes"
"encoding/csv"
"encoding/json"
"fmt"
"net/http"
"os"
"strconv"
"time"
)
type page struct {
Data []map[string]any `json:"data"`
Meta struct {
NextCursor *string `json:"nextCursor"`
} `json:"meta"`
}
func post(body map[string]any) (*page, error) {
b, _ := json.Marshal(body)
for try := 0; try < 5; try++ {
req, _ := http.NewRequest("POST", "https://api.leadocean.io/v1/people/search", bytes.NewReader(b))
req.Header.Set("x-api-key", os.Getenv("LEADOCEAN_API_KEY"))
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if res.StatusCode == 429 {
res.Body.Close()
wait, _ := strconv.Atoi(res.Header.Get("Retry-After"))
time.Sleep(time.Duration(max(wait, 1)) * time.Second)
continue
}
defer res.Body.Close()
if res.StatusCode != 200 {
return nil, fmt.Errorf("status %d", res.StatusCode)
}
var p page
return &p, json.NewDecoder(res.Body).Decode(&p)
}
return nil, fmt.Errorf("still rate limited")
}
func main() {
f, _ := os.Create("people.csv")
w := csv.NewWriter(f)
defer w.Flush()
body := map[string]any{"title": []string{"Head of Sales"}, "country": []string{"US"}, "limit": 25}
for n := 0; n < 4; n++ {
p, err := post(body)
if err != nil {
panic(err)
}
for _, row := range p.Data {
j, _ := json.Marshal(row)
w.Write([]string{string(j)})
}
if p.Meta.NextCursor == nil {
break
}
body["cursor"] = *p.Meta.NextCursor
}
}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
go run main.go
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, decode profile_data and contact_data into your own struct 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.
{
"total": 6564,
"totalIsExact": true,
"totalAll": 16363,
"distinctCompanies": 4932,
"credits": 0
}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, 6,564 rows is 263 pages and 16,363 is 655. 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 go 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 | Free plan only: the 1,000 records are spent and do not reset | Move to a paid plan. Paid plans are never refused: past their pace they slow to 1 request a minute (a 429). |
| 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 or a 400 | 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 run pages in parallel?
No. A cursor is a position in one walk, so each page depends on the last. Run separate searches in parallel, within your rate limit.
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 C#, TypeScript and Rust. All of them sit under the API tutorials hub, and plans are on pricing.
Run your first Go 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 →