TL;DR
- A live TAM list is three parts: a filter definition, a stored set of company domains, and a job that diffs that set against a fresh pull. This page builds all three.
- It takes about 45 minutes. Sizing spends 0 records. The first walk spends 1 record per company, and every run after that spends records only for slices whose free count moved.
- Free is enough for one small slice. Agency at $497 a month is the plan for a team that re-walks a full TAM every month. Solo at $97 suits one person with a smaller TAM.
Prerequisites
- A LeadOcean account from app.leadocean.io. Free gives you 1,000 records, one-off, no card. Free keys carry both the search and enrich scopes.
- Your API key exported as
$LEADOCEAN_API_KEY. - Python 3 with
requests, pluscurlandjq. SQLite ships with Python. - A machine that can run cron: a laptop, a small server or any CI runner.
export LEADOCEAN_API_KEY="paste-your-key-here"
pip install requests
mkdir tam && cd tamStep 1: Write the TAM as a config file
Your TAM is a set of filters, so put them in one file. Every change to the definition then shows up in version control.
This example is US-headquartered software companies with 51 to 1,000 staff. Each headcount bracket is its own slice. Slicing keeps every walk under the 10,000-row depth cap, and it lets you re-walk only the bracket that changed.
{
"filters": {
"industry": ["Software Development"],
"hqCountry": ["US"]
},
"slices": [
{"employeeRange": ["51-200"]},
{"employeeRange": ["201-500"]},
{"employeeRange": ["501-1000"]}
],
"buyers": {
"jobLevel": ["VP", "C-Team"],
"jobFunction": ["Sales & Business Development"]
}
}Save it as tam.json. Enum values are exact strings, and a guessed value returns zero rows. Resolve them first with GET /v1/enums/industry or the free leadocean_list_enum_values MCP tool. Software alone has seven industry values, including Computer Software and Software Development.
Step 2: Size each slice for free
/v1/companies/count is free and exact. It takes the same filters as company search, as a flat JSON body, and returns data.total with breakdowns by industry, headcount band, HQ country, HQ state and NAICS. It returns no rows.
for range in "51-200" "201-500" "501-1000"; do
curl -s -X POST "https://api.leadocean.io/v1/companies/count" \
-H "x-api-key: $LEADOCEAN_API_KEY" -H "content-type: application/json" \
-d "{\"industry\":[\"Software Development\"],\"hqCountry\":[\"US\"],\"employeeRange\":[\"$range\"]}" \
| jq -c --arg r "$range" '{slice: $r, total: .data.total, exact: .data.totalIsExact}'
doneIf data.total is null, a filter could not be applied: meta.unsupportedFilters names it. Fix the filter before you trust a diff. Any slice over 10,000 companies also needs splitting, by hqState or industry, because one walk stops at 10,000 rows.
Sum the three totals. That is your account TAM today. On Free, pick a slice small enough to fit inside 1,000 records before you walk it.
Step 3: Write the sync script
The script does four things. It counts each slice, skips any slice whose total matches the last run, walks the rest with the cursor, and diffs domains against SQLite. A --full flag forces every slice.
import datetime, json, os, sqlite3, sys, requests
BASE = "https://api.leadocean.io"
HDR = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "content-type": "application/json"}
CFG = json.load(open("tam.json"))
FULL = "--full" in sys.argv
TODAY = datetime.date.today().isoformat()
db = sqlite3.connect("tam.db")
db.executescript("""
create table if not exists company (domain text primary key, name text, slice text, first_seen text, last_seen text);
create table if not exists slice_state (slice text primary key, total integer);
""")
def post(path, body, params=None):
r = requests.post(BASE + path, headers=HDR, json=body, params=params, timeout=60)
if r.status_code in (402, 429):
sys.exit(f"{r.status_code}: {r.text}")
r.raise_for_status()
return r.json()
def count(filters):
data = post("/v1/companies/count", filters)["data"]
if data.get("total") is None:
sys.exit(f"count withheld for {filters}: check meta.unsupportedFilters")
if data["total"] > 10000:
sys.exit(f"{data['total']} companies in {filters}: split the slice")
return data["total"]
def walk(filters):
seen, cursor = {}, None
while True:
body = {**filters, "limit": 100}
if cursor:
body["cursor"] = cursor
page = post("/v1/companies/search", body)
for c in page["data"]:
d = c.get("company_data") or {}
if d.get("company_domain"):
seen[d["company_domain"]] = d.get("company_name")
meta = page["meta"]
if meta.get("depthCapped"):
sys.exit("depth cap hit: split this slice before diffing")
cursor = meta.get("nextCursor")
if not cursor:
return seen
new, gone, walked = [], [], 0
for sl in CFG["slices"]:
key = json.dumps(sl, sort_keys=True)
filters = {**CFG["filters"], **sl}
total = count(filters)
row = db.execute("select total from slice_state where slice=?", (key,)).fetchone()
if row and row[0] == total and not FULL:
print(f"skip {key}: total unchanged at {total}")
continue
seen, walked = walk(filters), walked + 1
everyone = {d for (d,) in db.execute("select domain from company")}
in_slice = {d for (d,) in db.execute("select domain from company where slice=?", (key,))}
new += sorted(set(seen) - everyone)
gone += sorted(in_slice - set(seen))
for d, n in seen.items():
db.execute("""insert into company values (?,?,?,?,?)
on conflict(domain) do update set name=excluded.name, slice=excluded.slice, last_seen=excluded.last_seen""",
(d, n, key, TODAY, TODAY))
for d in in_slice - set(seen):
db.execute("delete from company where domain=? and slice=?", (d, key))
db.execute("insert or replace into slice_state values (?,?)", (key, total))
db.commit()
moved = set(new) & set(gone) # changed headcount bracket, still in the TAM
new = [d for d in new if d not in moved]
gone = [d for d in gone if d not in moved]
open("new_domains.txt", "w").write("\n".join(new))
open("gone_domains.txt", "w").write("\n".join(gone))
print(f"{len(CFG['slices'])} slices checked, {walked} walked. new: {len(new)} gone: {len(gone)}")Each company returned counts one record. A walk that hits the depth cap stops before it writes anything, so a partial pull never marks healthy accounts as gone.
Step 4: Run the baseline and read the diff
The first run has no stored totals, so it walks every slice. That is your baseline, and its cost is one record per company.
python3 tam.py
sqlite3 tam.db "select slice, count(*) from company group by slice"
head new_domains.txtThe second run is the test. Run it straight away and every slice should print skip, which spends 0 records. A skip means the free count matched, not that the data is identical.
That is the weak spot of a count gate, and it is why the monthly pass exists. One company leaving and one joining leaves the total unchanged. Run python3 tam.py --full once a month to catch those swaps. The dataset is refreshed monthly, and each record carries its own fetched_at date.
Here is the cost logic in plain numbers. Say the 501-1000 slice moved and the other two are unchanged. A gated week spends 803 records, not 12,380. An ungated week would re-walk every slice and spend all of them.
Paid plans have no per-record price and no credits. The gate still helps there, because it keeps the job fast and keeps you well under the plan's fair-use pace.
Step 5: Pull buyers for new accounts only
Do not re-pull people for the whole TAM. Search by the domains in new_domains.txt and filter to your buyer roles. People search takes a domain array.
import json, os, requests
HDR = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "content-type": "application/json"}
buyers = json.load(open("tam.json"))["buyers"]
domains = [x.strip() for x in open("new_domains.txt") if x.strip()]
with open("new_buyers.jsonl", "a") as out:
for i in range(0, len(domains), 50):
cursor = None
while True:
body = {**buyers, "domain": domains[i:i + 50], "limit": 100}
if cursor:
body["cursor"] = cursor
r = requests.post("https://api.leadocean.io/v1/people/search", headers=HDR, json=body, timeout=60)
r.raise_for_status()
page = r.json()
out.writelines(json.dumps(row) + "\n" for row in page["data"])
cursor = page["meta"].get("nextCursor")
if not cursor:
breakEach person returned counts one record. Search rows carry no contact points. To get an email, enrich the person by person_id with reveal_email set to true, which costs no extra record.
For a first load of the whole buyer set, use POST /v1/exports instead, on a paid plan: bulk CSV export is not on Free. It takes the same filters as people search, up to 50,000 rows a request and 500,000 rows a day. It is the better route when the buyer pool runs into the tens of thousands.
Step 6: Schedule it
Put both scripts on cron with crontab -e. The weekly run is gated by the free count. The monthly run is a full pass.
0 6 * * 1 cd /srv/tam && python3 tam.py && python3 sync_buyers.py
0 6 2 * * cd /srv/tam && python3 tam.py --full && python3 sync_buyers.pySave the Step 5 script as sync_buyers.py. The && matters. If the sync fails on a 402 or 429, the buyer step never runs on a stale new_domains.txt. Because tam.py rewrites that file on every successful run, an empty week produces an empty file and spends nothing.
The first line runs every Monday at 06:00 and the second runs on the 2nd of each month. Send new_domains.txt wherever your team works. A Slack webhook, a CRM import or a Sheets append all take a plain list of domains.
What you get
Both sides of the TAM have a free count. On 2026-10-21 leadocean_count_companies returned this for the account side, the three slices in tam.json. It cost 0 records. Trimmed:
{
"total": 12380,
"totalIsExact": true,
"facets": {
"employee_range": [
{"value": "51-200", "count": 9261},
{"value": "201-500", "count": 2316},
{"value": "501-1000", "count": 803}
],
"hq_state": [
{"value": "California", "count": 3306},
{"value": "New York", "count": 1193},
{"value": "Texas", "count": 1017}
]
}
}Every slice is under the 10,000-row walk depth, so the script runs as written. The 51-200 slice is the one to split by hqState if it grows.
The same day, leadocean_count_leads sized the buyer side: VP and C-level sales leaders at US-headquartered software companies with 51 to 500 staff. It counts verified, catch_all_valid and catch_all addresses only, so it is the mailable pool. Trimmed:
{
"total": 4817,
"totalIsExact": true,
"totalAll": 12139,
"distinctCompanies": 2291,
"facets": {
"seniority": [
{"value": "vp", "count": 3617},
{"value": "c_suite", "count": 1071},
{"value": "owner", "count": 129}
],
"company_size": [
{"value": "51-200", "count": 2806},
{"value": "201-500", "count": 2011}
]
}
}The account side comes from the company search in Step 3. This is the response shape from the API reference, with placeholder values. Your sync script reads company_data.company_domain and nothing else.
{
"success": true,
"data": [
{ "company_data": { "company_name": "Acme", "company_domain": "acme.com" } }
],
"meta": {
"count": 1,
"limit": 100,
"nextCursor": "<opaque string, or null on the last page>"
}
}The script prints one summary line per run. This is the format, not a real run:
skip {"employeeRange": ["51-200"]}: total unchanged at <n>
3 slices checked, 2 walked. new: <n> gone: <n>Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Copy the key again from the app and check $LEADOCEAN_API_KEY is set in the cron environment, which does not read your shell profile |
| 429 | Above the plan's rate limit: 5 requests a second on Free, 50 on Solo, 200 on Agency | Wait for the Retry-After header and retry. Walk slices in sequence, not in parallel |
| 402 | Records are spent. On Free the 1,000 are one-off | Check GET /v1/account. See pricing for Solo ($97) and Agency ($497) |
Empty data, no error | An enum value that is not an exact string | Resolve each value with GET /v1/enums/{name} and drop filters one at a time |
depthCapped is true, nextCursor is null | One search paged through 10,000 rows | Split the slice into narrower filters. Do not store cursors to resume a walk |
400 export_limit_rows or 429 export_limit_daily | The export asked for over 50,000 rows, or passed 500,000 rows in a day | Split the export into several smaller requests across days |
FAQ
How much does it cost to keep a TAM list fresh?
On a paid plan it costs the flat monthly price: Solo is $97 and Agency is $497, with a fair-use pace instead of credits. On Free, each walk spends 1 record per company, so the 1,000 records cover one baseline of a small slice and little more. The count gate keeps re-walks to the slices that moved.
How often does the underlying data change?
The dataset is refreshed monthly. Weekly runs still earn their keep, because the free counts cost nothing and the gate skips unchanged slices. Read meta.fetched_at on a record to see when it was last refreshed.
Can LeadOcean push me a notification when a company joins the list?
The API reference documents no webhook or change feed, so you poll. The count gate in this recipe is the cheap way to poll.
Can an agent run the free part?
Yes. The hosted MCP server at https://api.leadocean.io/mcp has two free counts: leadocean_count_companies for accounts and leadocean_count_leads for people. Company walks use leadocean_search_companies, which spends 1 record per company. See size your TAM in Claude Code.
What do I read next?
Start with how to build a targeted prospect list for the filter choices. Then see how to build a TAM model in Snowflake with SQL to model the totals, and company list building API for B2B for the endpoint tradeoffs. The rest of the series is on the blog hub.
Size your TAM for free, then keep it current
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →