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

How-to guide

How to Build a Live, Self-Updating TAM List (With an API)

Use the free count as a change check, walk only the slices that moved, and diff domains each week. About 90 lines of Python and two cron entries.

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

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, plus curl and jq. SQLite ships with Python.
  • A machine that can run cron: a laptop, a small server or any CI runner.
bash
export LEADOCEAN_API_KEY="paste-your-key-here"
pip install requests
mkdir tam && cd tam

Step 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.

json
{
  "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.

bash
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}'
done

If 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.

python
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.

bash
python3 tam.py
sqlite3 tam.db "select slice, count(*) from company group by slice"
head new_domains.txt

The 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.

python
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:
                break

Each 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.

bash
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.py

Save 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:

json
{
  "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:

json
{
  "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.

json
{
  "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:

text
skip {"employeeRange": ["51-200"]}: total unchanged at <n>
3 slices checked, 2 walked. new: <n>  gone: <n>

Troubleshooting

ErrorCauseFix
401Missing or wrong x-api-key headerCopy the key again from the app and check $LEADOCEAN_API_KEY is set in the cron environment, which does not read your shell profile
429Above the plan's rate limit: 5 requests a second on Free, 50 on Solo, 200 on AgencyWait for the Retry-After header and retry. Walk slices in sequence, not in parallel
402Records are spent. On Free the 1,000 are one-offCheck GET /v1/account. See pricing for Solo ($97) and Agency ($497)
Empty data, no errorAn enum value that is not an exact stringResolve each value with GET /v1/enums/{name} and drop filters one at a time
depthCapped is true, nextCursor is nullOne search paged through 10,000 rowsSplit the slice into narrower filters. Do not store cursors to resume a walk
400 export_limit_rows or 429 export_limit_dailyThe export asked for over 50,000 rows, or passed 500,000 rows in a daySplit 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 →