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=... curlandjq.- A file
people.csvwith alinkedin_urlcolumn, or anemailcolumn 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.
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.
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.
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.
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 -cThat 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.
grep -E ',(GAP|MISS),' hop1.csv | cut -d, -f1 > leftovers.txt
wc -l leftovers.txtleftovers.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).
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:
{
"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:
{
"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
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Re-export $LEADOCEAN_API_KEY. The header is x-api-key, not Authorization. |
| 429 | Requests 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. |
| 402 | Records spent (1,000 for life on Free) | Stop the run or move to a paid plan. A 402 costs nothing. |
| 404 on a row | LeadOcean holds nothing for that person | Expected. Mark the row MISS and send it on. No record is spent. |
| Empty search results | Enum value spelled wrong, such as vp for jobLevel | jobLevel takes exact strings (VP, C-Team, Director). seniority is lowercase. List valid values with GET /v1/enums. |
| Export refused | Over 50,000 rows in one request or 500,000 in a day, or a bulk export on Free | Split 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 →