A company website gives you role inboxes and a domain, rarely a person's address. Collect what the site publishes with a short script, then use the domain to look up the person you want.
TL;DR
- A company site mostly shows role inboxes such as info@ and sales@. The useful things on it are the domain and the names on the team page.
- It takes about 10 minutes for one site. The scrape costs nothing. The API step spends about 4 LeadOcean records per person: 3 for a search at
limit3, 1 for the work-email lookup. - Collect addresses with a short script, then search the domain for the person you want and read their
email_statusbefore you send.
Prerequisites
- A LeadOcean account from app.leadocean.io. The free plan has no card and gives 1,000 records, one-off.
- Python 3 (the script uses only the standard library),
curlandjq. - An API key in your shell as
$LEADOCEAN_API_KEY. Free keys get both scopes, search and enrich.
export LEADOCEAN_API_KEY="paste-your-key-here"Step 1: Check the likely pages by hand
Open the contact, team, about, press and careers pages, then the footer. Search the page source (Cmd+Option+U or Ctrl+U) for mailto: and for @acme.com.
That catches addresses hidden behind an "Email us" button. If the source is nearly empty, the page is rendered by JavaScript. Search the live DOM in DevTools instead.
site:acme.com "@acme.com"The search operator above asks a search engine for indexed pages that mention the domain's addresses, including old PDFs and blog posts. For five sites, stop here. For more, script it.
Step 2: Script the collection
This script fetches a short list of paths, pulls addresses from mailto: links and visible text, and keeps only the site's own domain. It checks robots.txt first and flags role inboxes.
import re, sys, time, urllib.request, urllib.robotparser
from urllib.parse import urljoin, urlparse
PATHS = ["/", "/contact", "/about", "/team", "/press", "/careers"]
ROLES = {"info", "sales", "hello", "contact", "support", "press", "jobs", "careers", "privacy", "legal"}
EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
UA = "site-email-audit/1.0"
def fetch(url):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=10) as r:
return r.read().decode("utf-8", "ignore")
base = sys.argv[1].rstrip("/") # https://acme.com
domain = urlparse(base).netloc.removeprefix("www.")
rp = urllib.robotparser.RobotFileParser(base + "/robots.txt")
rp.read()
found = set()
for path in PATHS:
url = urljoin(base + "/", path.lstrip("/"))
if not rp.can_fetch(UA, url):
continue
try:
html = fetch(url)
except Exception as e:
print("skip", url, e, file=sys.stderr)
continue
found |= {m.lower() for m in EMAIL.findall(html) if m.lower().endswith("@" + domain)}
time.sleep(1)
for addr in sorted(found):
kind = "role" if addr.split("@")[0] in ROLES else "named"
print(addr, kind)python3 find_emails.py https://acme.comExpect a short list, mostly role. Sites hide named addresses behind forms, images or scripts on purpose. Respect robots.txt and the site's terms, and keep the delay.
Step 3: Carry the domain and the names forward
The page gave you two things worth keeping: the domain, and the names and titles on the team page. A role inbox is shared and rarely read by the person you want.
Size the people we hold at that domain for free. A search with count=true as a query parameter and limit at 1 spends no records.
curl -s "https://api.leadocean.io/v1/people/search?count=true" \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
-d '{
"domain": ["acme.com"],
"emailStatus": ["verified", "catch_all_valid", "catch_all"],
"limit": 1
}' | jq '.meta | {total, totalIsExact, credits}'Step 4: Search the domain for the person
An address belongs to a person, so search the domain with a title from the team page. Keep limit small. Each person returned costs one record.
curl -s "https://api.leadocean.io/v1/people/search" \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
-d '{
"domain": ["acme.com"],
"title": ["Head of Sales", "VP Sales"],
"limit": 3
}' | jq '{count: .meta.count, people: [.data[] | {name: .profile_data.profile_full_name, person_id: .meta.source_ids.person_id}]}'A search row is thin. It carries no address, but it has a person_id at meta.source_ids.person_id. To list a whole company, use GET /v1/companies/acme.com/people. It pages with a cursor and stops at 10,000 rows.
Step 5: Get the work email
Send the person_id to the work-email endpoint. It returns one address or null, and only an address at the current employer that is verified, catch_all_valid or catch_all.
PERSON_ID="paste-person_id-from-step-4"
curl -s "https://api.leadocean.io/v2/people/email/work?person_id=$PERSON_ID" \
-H "x-api-key: $LEADOCEAN_API_KEY" | jq '.data'This costs one record per call, including a call that finds nothing. For a whole list of domains, use POST /v1/exports, which needs a paid plan. See how to find an email from a company domain.
What you get
The Step 3 count is a real run. We ran it on 2026-10-21 with leadocean_count_leads, which spends 0 records, for the placeholder domain acme.com.
{
"total": 32,
"totalIsExact": true,
"totalAll": 707,
"distinctCompanies": 2,
"credits": 0,
"unsupportedFilters": []
}That is 32 people whose email status is verified, catch_all_valid or catch_all, the count tool's default. With no email condition the same filter matches 707 (totalAll). Say which one you used. A distinctCompanies of 2 means the matched people span two employer records, so check the company on each row.
The work-email lookup returns this shape. It is the response shape from the API reference, with placeholder values.
{
"success": true,
"data": {
"email": "jane.doe@acme.com",
"status": "verified",
"verified_batch_date": "2026-09-20"
}
}LeadOcean gives no refund or credit-back for bounced emails, so read status before you send.
Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing, wrong or revoked key | Re-copy the key from app.leadocean.io and export $LEADOCEAN_API_KEY again. |
| 429 | Above your plan's rate: 5 requests a second on Free, 50 on Solo, 200 on Agency, per account | Wait the Retry-After seconds, then slow the loop. |
| 402 | The 1,000 free records are spent | Check GET /v1/account, or move to Solo ($97 a month) or Agency ($497 a month) on pricing. |
| Search returns no rows | A filter value outside the catalogue, such as a job level of "VP Sales" | Check exact strings with GET /v1/enums. Job level is VP. |
data: null on the work email | No address at the current employer is verified, catch_all_valid or catch_all | Costs one record. Try POST /v1/people/enrich with reveal_email, or pick another person. |
| Script prints nothing | The site renders in JavaScript, blocks bots or disallows the paths in robots.txt | Check the pages by hand. Do not work around a block. |
FAQ
Is it legal to scrape emails from a website?
Ask your lawyer. We do not give legal advice. Read the site's terms and robots.txt, and check the rules for the country you send to before you email anyone.
Why do I only find info@ addresses?
Companies publish addresses for functions, not people. Named addresses are rare on public pages. Use the domain and team-page names to look the person up instead.
How many records does it cost?
The scrape and the count cost nothing. A search at limit 3 plus one work-email call spends about 4 records.
Can I find an email from a name and a company?
Yes. There is no name-plus-domain lookup, so search the domain first, then look up by person_id. Read how to find an email from name and company.
Where do I get a list of domains to start from?
A startup directory works. See how to find emails from a YC company list. More recipes are in the blog hub.
Turn any company website into a checked work email, free
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →