A bounce checker is a one-time scan. Bounce management is a loop: classify each failure, suppress the dead addresses, gate the next list on email status, and watch the rate. This guide builds that loop in five steps.
Other guides on this topic stay conceptual. This one shows the SMTP codes, the script and the API queries. Examples use placeholders (Acme, acme.com, Jane Doe).
TL;DR
- Classify every bounce by its enhanced status code, not by the word in the message.
5.x.xis permanent,4.x.xis temporary. - Suppress permanent failures at once. Gate the next list on
emailStatusbefore you pull it. Sizing is free. - Takes about 25 minutes and spends about 3 of the 1,000 free LeadOcean records.
Prerequisites
- A LeadOcean key from app.leadocean.io, exported as
$LEADOCEAN_API_KEY. The Free plan works: no card, 1,000 records for life. - A bounce log from your sender: a CSV with
emailand the raw bounce message. curl,jqand Python 3.
All requests go to https://api.leadocean.io with the header x-api-key.
Step 1: Classify each bounce by its code
The enhanced status code in the bounce message says whether a retry can ever work. The format is defined in RFC 3463 (checked September 2026): a first digit of 4 is a persistent transient failure, 5 is a permanent failure.
import csv, re, sys
CODE = re.compile(r"\b([245])\.(\d{1,3})\.(\d{1,3})\b")
def classify(message: str) -> str:
m = CODE.search(message)
if not m:
return "unknown"
cls, subject, detail = m.group(1), m.group(2), m.group(3)
if cls == "5":
return "hard" # 5.1.1 is a mailbox that does not exist
if cls == "4":
return "soft" # 4.2.2 is a full mailbox
return "ok"
for row in csv.DictReader(open(sys.argv[1])):
print(row["email"], classify(row["message"]))Treat unknown as soft for one retry and hard after that. Do not trust keywords like "blocked" in free text: a 5.7.1 policy block is permanent for that message, but it says nothing about the mailbox.
Step 2: Build the suppression list
Hard bounces go onto a suppression list the same day. Soft bounces get a retry count, and the third failure moves to the list.
from collections import Counter
soft = Counter()
suppress = set()
for email, kind in classified: # output of Step 1
if kind == "hard":
suppress.add(email.lower())
elif kind in ("soft", "unknown"):
soft[email.lower()] += 1
if soft[email.lower()] >= 3:
suppress.add(email.lower())
open("suppress.txt", "w").write("\n".join(sorted(suppress)))Load suppress.txt into your sequencer's global block list, not into one campaign. A dead address that is only blocked in one campaign bounces again in the next.
Step 3: Gate the next list on email status
The cheapest bounce is the one you never send. Size the list by status before you pull it. A count returns a total and no rows, and costs nothing.
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 '{"jobLevel":["VP"],"country":["US"],"emailStatus":["verified"],"emailType":["work"]}' \
| jq '.meta | {total, totalIsExact, distinctCompanies}'Then run the same request with the statuses you refuse (invalid, risky, disposable, spam_trap, abuse) to see what a plain export would have put in your sequence. From the MCP server, the free tool is leadocean_count_leads. Pass emailStatus yourself: the default counts verified, catch_all_valid and catch_all.
Pull rows only with the status you chose:
{
"jobLevel": ["VP"],
"country": ["US"],
"emailStatus": ["verified"],
"emailType": ["work"],
"limit": 25
}emailType: work means the address is at the person's current employer. LeadOcean does not refund or credit back bounced emails, so read the status before you send.
Step 4: Re-check addresses that bounced once
A soft bounce can be a full inbox. It can also be a person who left. Ask who holds the address now with a reverse lookup.
curl -s "https://api.leadocean.io/v2/people/reverse?email=jane.doe@acme.com" \
-H "x-api-key: $LEADOCEAN_API_KEY" | jq .A 404 means no person holds the address, so suppress it. A hit returns the person, plus the address's own type, status and check date. If the employer is not Acme, the person changed jobs and the address is stale. Each call costs one record, found or not, so run it on the soft-bounce group only.
To find where the person went, enrich by linkedin_url or person_id:
curl -s -X POST https://api.leadocean.io/v1/people/enrich \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"person_id":"1234567890","reveal_email":true}' \
| jq '.data.contact_data.contact_emails[] | {email, type, status, verified_batch_date}'An enrichment that finds nothing is a 404 and costs nothing.
Step 5: Track the rate and route by status
Compute the bounce rate per send, not per month. One bad list is visible in one send and hidden in a monthly average.
def bounce_rate(sent: int, hard: int, soft: int) -> dict:
return {
"hard_pct": round(100 * hard / sent, 2),
"soft_pct": round(100 * soft / sent, 2),
}
STATUS_ROUTE = {
"verified": "send",
"catch_all_valid": "send, watch",
"catch_all": "separate batch",
"risky": "hold",
"unknown": "hold",
"untested": "hold",
"role": "hold",
"invalid": "drop",
"disposable": "drop",
"spam_trap": "drop",
"abuse": "drop",
}Set your own ceiling for hard bounces and stop a send that crosses it. See email bounce rate for how to read the number and soft bounce email for retry timing. Why emails bounce covers the causes. The wider workflow is in the deliverability guide.
What you get
Counts below were run on 2026-10-21 with the free leadocean_count_leads tool for jobLevel VP in the US. No rows were returned and no records were spent.
| Filter | People |
|---|---|
hasEmail: true (any status) | 2,840,793 |
emailStatus: verified only | 257,840 |
emailStatus: invalid, risky, disposable, spam_trap, abuse | 354,526 |
These are counts by the status you filtered, not mailable counts. The refused group is larger than the verified group. That is the case for gating on status at the source.
Enrichment data object. Response shape from the API reference (openapi.json, October 2026), placeholder values.
{
"contact_data": {
"has_email": true,
"email_status": "verified",
"email_type": "work",
"contact_emails": [
{
"email": "jane.doe@acme.com",
"type": "work",
"status": "verified",
"verified_batch_date": "2026-09-20"
}
]
}
}Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Re-export $LEADOCEAN_API_KEY and check the header name |
| 429 | Above the plan's rate limit: 5 requests a second on Free, 200 on Agency | Wait for the Retry-After value, then retry |
| 402 | The 1,000 free records are spent | Move to Solo ($97 a month) or Agency ($497 a month) |
| Empty results | A filter value is not an exact enum string, such as vp in jobLevel | List values with /v1/enums/job_level and copy one |
| 404 on reverse or enrich | Nobody holds that address or identifier | Suppress the address. A 404 costs nothing on enrich |
meta.depthCapped is true | One search pages through 10,000 rows at most | Split the search into narrower filters |
FAQ
What is the difference between a bounce checker and bounce management?
A checker scans addresses once and reports failures. Management is the loop after it: classify, suppress, gate the next list and track the rate per send.
Is a hard bounce ever worth retrying?
No. A 5.x.x code is permanent for that address. Retrying adds bounces and hurts your sender reputation.
How many soft bounces before I suppress an address?
Three is a sound default, as in Step 2. Change it if your sender retries on its own, so you do not count the same message twice.
Does LeadOcean refund bounced emails?
No. There is no refund or credit-back for bounces. Gate on emailStatus first and re-check suspect addresses at enrichment.
Which plan do I need?
Free covers this walkthrough. Solo is $97 a month for one person, and Agency is $497 a month for teams. See pricing.
Stop bounces before the send. Filter by email status for free.
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →