Pre-Black Friday: 20% off, for as long as you stayEARLYBFon Solo and AgencyEnds Nov 28Start free

Enrichment how-to

How to run a waterfall enrichment (with one API and a stop rule)

A waterfall asks sources in order and stops at the first good answer. Here is the loop in curl and jq, with the record cost of each hop.

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

A waterfall enrichment asks data sources one at a time and stops at the first answer you trust. You pay the next source only for the rows the last one missed. The whole idea is an ordered list and a stop rule.

This guide builds the loop with curl and jq. It uses LeadOcean as the first hop because a miss there costs nothing.

TL;DR

  • A waterfall is three things: an order of sources, a stop rule per row, and a hand-off of the leftovers.
  • Setup takes about 20 minutes. A 100-row test spends at most 100 LeadOcean records, and a 404 miss spends none (LeadOcean docs, October 2026).
  • Put the source that charges nothing for a miss first. Put the expensive source last and feed it only the gaps.

Prerequisites

  • A LeadOcean key from app.leadocean.io/register. The free plan has 1,000 records for life and no card.
  • The key in your shell: export LEADOCEAN_API_KEY=...
  • curl and jq.
  • A file people.csv with a linkedin_url column, or an email column for rows without a profile.

Step 1: Size the list before you spend anything

Count first. A search with count=true and limit 1 is free, and meta.total is capped at 100,000 (API reference, October 2026). Send count=true as a query parameter and limit in the JSON body.

bash
curl -s -X POST "https://api.leadocean.io/v1/people/search?count=true" \
  -H "x-api-key: $LEADOCEAN_API_KEY" \
  -H "content-type: application/json" \
  -d '{"jobLevel":["VP"],"jobFunction":["Sales & Business Development"],"country":["CA"],"limit":1}' \
  | jq '.meta | {total, totalIsExact, distinctCompanies}'

Do this for the audience you plan to enrich. If the number is small, stop and widen the filter before you build anything.

Step 2: Hop 1, enrich by LinkedIn URL

Hop 1 is POST /v1/people/enrich. Send exactly one identifier: linkedin_url, email, phone or person_id. Set reveal_email to true to get the addresses and numbers, at no extra record.

bash
curl -s -X POST "https://api.leadocean.io/v1/people/enrich" \
  -H "x-api-key: $LEADOCEAN_API_KEY" \
  -H "content-type: application/json" \
  -d '{"linkedin_url":"https://www.linkedin.com/in/jane-doe","reveal_email":true}'

A hit costs one record. A person LeadOcean holds nothing for is a 404 and costs nothing. That is why this hop goes first: a miss is free, so ordering it ahead of paid sources cannot lose you money.

Step 3: Write the stop rule

The stop rule decides whether a hop's answer is good enough to end the row. Without it you either re-query rows you already have or send junk downstream. For cold email, accept verified and catch_all_valid and nothing else.

bash
resp=$(curl -s -w '\n%{http_code}' -X POST "https://api.leadocean.io/v1/people/enrich" \
  -H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
  -d "{\"linkedin_url\":\"$1\",\"reveal_email\":true}")
code=$(echo "$resp" | tail -n1); body=$(echo "$resp" | sed '$d')

if [ "$code" = "404" ]; then echo "$1,MISS,,"; exit 0; fi
echo "$body" | jq -r --arg u "$1" '
  .data.contact_data as $c
  | ($c.contact_emails // [] | map(select(.type=="work" and (.status=="verified" or .status=="catch_all_valid"))) | .[0].email) as $e
  | if $e then "\($u),STOP,\($e),\($c.contact_phones[0].phone // "")"
    else "\($u),GAP,,\($c.contact_phones[0].phone // "")" end'

Save the script as stop.sh and call it with one URL. Three outcomes come out: STOP (good email, row done), GAP (the person exists but no qualifying email) and MISS (not in the data). Keep any phone you got on a GAP row. It is already paid for.

Step 4: Run the loop

Rows with no LinkedIn URL can still go to hop 1 by email: swap the body key from linkedin_url to email. Run the file through the script and respect the rate limit: 5 requests a second on Free, 200 on Agency.

bash
tail -n +2 people.csv | cut -d, -f1 | while read url; do
  ./stop.sh "$url"; sleep 0.25
done > hop1.csv

cut -d, -f2 hop1.csv | sort | uniq -c

That last line prints how many rows landed in each outcome. Read it before you add a second source. It is your real hit rate, and it sets the budget for everything after hop 1.

Step 5: Send only the leftovers to hop 2

Take the GAP and MISS rows and hand them to the next source. Do not rerun STOP rows.

bash
grep -E ',(GAP|MISS),' hop1.csv | cut -d, -f1 > leftovers.txt
wc -l leftovers.txt

leftovers.txt is the input to the next provider in your order, whether that is a second API or a table tool. Clay, for example, runs an enrichment only when a condition holds, such as an email field being empty (Clay docs, October 2026). The same hand-off is described in the LeadOcean and Clay recipe.

Step 6: Audit what each hop cost

Check the spend after the run. GET /v1/usage and GET /v1/account are free calls (LeadOcean docs, October 2026).

bash
curl -s "https://api.leadocean.io/v1/usage" -H "x-api-key: $LEADOCEAN_API_KEY" | jq .

Then do the arithmetic per hop. Hop cost is the hits it returned times its price per hit, plus any charge it makes on misses. Divide by the rows it filled. That number, not the list price, is what a filled row costs you.

The same applies inside LeadOcean. The /v2/people/email/work and /v2/people/phone endpoints cost one record per call, even when they find nothing. They read the same cached record as enrich (API reference, October 2026). So do not use them as extra hops after enrich. Use them when you need only one field and no profile.

What you get

Step 1 returns a free count. The same count through the MCP tool leadocean_count_leads, run on 2026-10-21 for VP level, Sales & Business Development, country CA. The result:

json
{
  "total": 5574,
  "totalIsExact": true,
  "totalAll": 18800,
  "distinctCompanies": 3873,
  "credits": 0,
  "defaults": "Counted only addresses worth sending to (emailStatus: verified or catch_all_valid or catch_all)"
}

That tool counts verified, catch_all_valid and catch_all addresses by default. So 5,574 is the mailable count and 18,800 is everyone matching, with or without an email. It cost 0 records.

Step 2 returns one person. This is the response shape from the API reference, with placeholder values:

json
{
  "success": true,
  "data": {
    "contact_data": {
      "has_email": true,
      "email_status": "verified",
      "email_type": "work",
      "contact_emails": [
        { "email": "jane.doe@acme.com", "type": "work", "status": "verified" }
      ],
      "contact_phones": [
        { "phone": "+14155550133", "type": "mobile" }
      ]
    }
  }
}

The stop rule reads contact_emails[].type and .status. The has_email and has_phone flags come back even without reveal_email, so you can see what exists before you ask for it.

Troubleshooting

ErrorCauseFix
401Missing or wrong x-api-key headerRe-export $LEADOCEAN_API_KEY. The header is x-api-key, not Authorization.
429Requests above your plan's rate limit (5 a second on Free, 200 on Agency)Slow the loop and honor the Retry-After header. A 429 costs nothing.
402Records spent (1,000 for life on Free)Stop the run or move to a paid plan. A 402 costs nothing.
404 on a rowLeadOcean holds nothing for that personExpected. Mark the row MISS and send it on. No record is spent.
Empty search resultsEnum value spelled wrong, such as vp for jobLeveljobLevel takes exact strings (VP, C-Team, Director). seniority is lowercase. List valid values with GET /v1/enums.
Export refusedOver 50,000 rows in one request or 500,000 in a day, or a bulk export on FreeSplit the request. Bulk CSV export is not on Free (LeadOcean docs, October 2026).

FAQ

Is a waterfall the same as running several tools at once?

No. A waterfall is sequential. Each source runs only on rows the one before it left unfilled, which is how you avoid paying twice for the same row.

How many sources should a waterfall have?

Two or three. Each added hop has a lower hit rate on the leftovers, because the easy rows are already filled. Measure the fill rate of every hop and drop one that adds little.

Which source goes first?

Order by cost on a miss first, then by coverage. A source that bills for an empty answer belongs late, where it sees few rows. LeadOcean charges nothing for a 404, so it fits the first slot. A people-search row also publishes has_email, email_status and email_type for free, so you can skip enrichment for rows that cannot qualify. See the ranking of waterfall tools for the table-based options.

Why not accept catch_all as a pass?

A catch_all domain accepts every address, so the status does not show the mailbox exists. catch_all_valid means the mailbox was confirmed on such a domain. Treat plain catch_all as a gap, or send it to a verifier.

When is a waterfall the wrong tool?

When you can state the audience as filters. Search it instead: rows come back with the email flags attached, and POST /v1/exports returns up to 50,000 rows a request (not on Free). A waterfall also cannot fix a bad stop rule, so fix the rule before you add a source.

For the concept, read what waterfall enrichment is and the longer explainer. Plan pricing is on the pricing page, and more guides are on the blog.

Run the first hop of your waterfall free

Free to start. No credit card. 1,000 records to spend whenever you like.

Get your free API key →