TL;DR
- A bottom-up TAM model is a table of counts per market slice, a table of your own deal assumptions, and one view that multiplies them. You can build all three in about 40 minutes.
- The counts are free. A search with
count=trueandlimit=1returnsmeta.totaland spends 0 records, so the model costs 0 records to build and to refresh. - Loading the named account list is optional and costs 1 record per company. Free covers a small slice. Solo at $97 a month or Agency at $497 covers the rest, with no per-record price.
Prerequisites
- A LeadOcean account from app.leadocean.io. Free gives you 1,000 records, one-off, no card.
- Your API key exported as
$LEADOCEAN_API_KEY. - A Snowflake account with a warehouse and a role that can create tables in one schema.
- Python 3 with
requestsandsnowflake-connector-python.
export LEADOCEAN_API_KEY="paste-your-key-here"
export SNOWFLAKE_ACCOUNT="your-account"
export SNOWFLAKE_USER="your-user"
export SNOWFLAKE_PASSWORD="your-password"
pip install requests snowflake-connector-pythonStep 1: Create the tables
Three tables keep the model honest. Counts live in one, your assumptions in another, and a snapshot date on every count lets you chart drift later.
create schema if not exists gtm.tam;
create table if not exists gtm.tam.slice_count (
snapshot_date date,
slice_key varchar,
companies number,
email_buyers number
);
create table if not exists gtm.tam.assumption (
slice_key varchar,
acv_usd number,
in_sam boolean,
win_share float
);
create table if not exists gtm.tam.company (
snapshot_date date,
slice_key varchar,
company_domain varchar,
company_name varchar
);A slice is one headcount bracket here. You could slice by country or industry instead. Pick the cut your pricing follows.
Step 2: Count each slice for free
Define the market once, then count it once per slice. The company count sizes the account side. The people count sizes the buyer side, filtered to the roles that sign.
import datetime, os, requests, snowflake.connector
BASE = "https://api.leadocean.io"
HDR = {"x-api-key": os.environ["LEADOCEAN_API_KEY"], "content-type": "application/json"}
MARKET = {"industry": ["Software Development"], "hqCountry": ["US"]}
SLICES = ["51-200", "201-500", "501-1000"]
BUYERS = {
"jobLevel": ["VP", "C-Team"],
"jobFunction": ["Sales & Business Development"],
"emailStatus": ["verified", "catch_all_valid", "catch_all"],
}
def count(path, body):
r = requests.post(BASE + path, headers=HDR, json=body,
params={"count": "true", "limit": 1}, timeout=60)
r.raise_for_status()
meta = r.json()["meta"]
if meta.get("total") is None or not meta.get("totalIsExact"):
raise SystemExit(f"count not exact for {body}: split the slice")
return meta["total"]
con = snowflake.connector.connect(
account=os.environ["SNOWFLAKE_ACCOUNT"], user=os.environ["SNOWFLAKE_USER"],
password=os.environ["SNOWFLAKE_PASSWORD"], warehouse="TAM_WH",
database="GTM", schema="TAM")
today = datetime.date.today().isoformat()
for s in SLICES:
f = {**MARKET, "employeeRange": [s]}
companies = count("/v1/companies/search", f)
buyers = count("/v1/people/search", {**f, **BUYERS})
con.cursor().execute(
"insert into slice_count values (%s, %s, %s, %s)", (today, s, companies, buyers))
print(s, companies, buyers)meta.total is capped at 100,000. When totalIsExact is false the total is a floor, so the script stops. Split that slice and rerun. Enum values are exact strings, and a guessed one returns zero rows. Resolve them with GET /v1/enums/industry or the free leadocean_list_enum_values MCP tool first.
The buyer count covers people whose best email is verified, catch_all_valid or catch_all, not everyone who matches. For an exact company total with breakdowns by industry, headcount band and HQ state, GET /v1/companies/count is also free.
Step 3: Add your deal assumptions
LeadOcean sizes the market. Your own numbers price it. These values are examples, so replace them with your average contract value, your SAM cut and your near-term win share.
insert into gtm.tam.assumption values
('51-200', 18000, true, 0.04),
('201-500', 30000, true, 0.03),
('501-1000', 60000, false, 0.01);SAM here is the two smaller brackets, because that is where the product sells today. Change in_sam and the whole model moves. Every figure downstream traces to a row you can read.
Step 4: Write the TAM, SAM and SOM view
Read only the latest snapshot. qualify keeps the newest date per slice without a subquery.
create or replace view gtm.tam.v_latest as
select * from gtm.tam.slice_count
qualify snapshot_date = max(snapshot_date) over ();
create or replace view gtm.tam.v_tam as
select
sum(c.companies) as tam_companies,
sum(c.companies * a.acv_usd) as tam_usd,
sum(iff(a.in_sam, c.companies, 0)) as sam_companies,
sum(iff(a.in_sam, c.companies * a.acv_usd, 0)) as sam_usd,
sum(iff(a.in_sam, c.companies * a.acv_usd * a.win_share, 0)) as som_usd,
sum(iff(a.in_sam, c.email_buyers, 0)) as sam_email_buyers
from gtm.tam.v_latest c
join gtm.tam.assumption a using (slice_key);The model is a join and a sum. TAM is companies times ACV across every slice. SAM is the same sum over the slices you can serve. SOM applies your win share to SAM.
Query it with select * from gtm.tam.v_tam.
Step 5: Load the account list (optional)
The view needs counts only. The named accounts are a second step, and each company returned counts one record. Load only the SAM slices, and walk them with the cursor.
def walk(filters):
cursor = None
while True:
body = {**filters, "limit": 100, **({"cursor": cursor} if cursor else {})}
r = requests.post(BASE + "/v1/companies/search", headers=HDR, json=body, timeout=60)
r.raise_for_status()
page = r.json()
for c in page["data"]:
d = c.get("company_data") or {}
if d.get("company_domain"):
yield d["company_domain"], d.get("company_name")
cursor = page["meta"].get("nextCursor")
if not cursor:
return
for s in ["51-200", "201-500"]:
rows = [(today, s, dom, name) for dom, name in walk({**MARKET, "employeeRange": [s]})]
con.cursor().executemany("insert into company values (%s, %s, %s, %s)", rows)For a buyer list, POST /v1/exports returns people as CSV, up to 50,000 rows a request and 500,000 a day. Bulk CSV export is on Solo and above, not on Free. Stage the file in Snowflake and copy it in.
Step 6: Track drift and schedule it
Run the count script on a schedule and the table grows one snapshot at a time. The dataset is refreshed monthly, so a weekly run is more than enough. The lag function turns snapshots into a change column.
select snapshot_date, tam_companies,
tam_companies - lag(tam_companies) over (order by snapshot_date) as change
from (
select snapshot_date, sum(companies) as tam_companies
from gtm.tam.slice_count
group by snapshot_date
)
order by snapshot_date;0 6 * * 1 cd /srv/tam && python3 count_slices.pyCron does not read your shell profile. Set the four environment variables inside the crontab or in the job's own environment.
What you get
These are real results from the free MCP tools on 2026-10-21. The company side is leadocean_count_companies (industry Software Development, HQ in the US). The buyer side is leadocean_count_leads with the same filters plus VP and C-Team sales leaders, using the tool's default of verified, catch_all_valid or catch_all email. Each call cost 0 records.
{"slice": "51-200", "companies": 9261, "buyers": 2806, "totalIsExact": true, "credits": 0}
{"slice": "201-500", "companies": 2316, "buyers": 2011, "totalIsExact": true, "credits": 0}
{"slice": "501-1000", "companies": 803, "buyers": 1354, "totalIsExact": true, "credits": 0}Loaded into slice_count, they give this result for select slice_key, companies, email_buyers from gtm.tam.v_latest order by 1. The slice keys sort as strings.
SLICE_KEY COMPANIES EMAIL_BUYERS
201-500 2316 2011
501-1000 803 1354
51-200 9261 2806The SAM buyer pool, the two smaller brackets, is 4,817. All three slices total 6,171 buyers at 12,380 companies.
The company side comes from POST /v1/companies/search. This is the response shape from the API reference, with placeholder values.
{
"success": true,
"data": [
{ "company_data": { "company_name": "Acme", "company_domain": "acme.com" } }
],
"meta": { "count": 1, "limit": 1, "total": "<n>", "totalIsExact": true, "credits": 0 }
}With the example assumptions from Step 3, the final view returns one row. The counts are real; the dollar figures are only as good as your ACV and win share.
TAM_COMPANIES TAM_USD SAM_COMPANIES SAM_USD SOM_USD SAM_EMAIL_BUYERS
12380 284358000 11577 236178000 8752320 4817Troubleshooting
| 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 where the script runs |
| 429 | Above your plan's rate limit: 5 a second on Free, 50 on Solo, 200 on Agency | Wait for Retry-After. Count slices in sequence, not in parallel |
| 402 | Records are spent. On Free the 1,000 are one-off | Check GET /v1/account. Counts stay free. See pricing: Solo is $97 a month, Agency $497 |
Total of 0 or empty data | An enum value that is not an exact string | Resolve each value with GET /v1/enums/{name} and drop filters one at a time |
meta.total is null or totalIsExact is false | A filter could not be applied, or the slice passed the 100,000 cap | Narrow the filters and count again. Do not store a floor as a TAM figure |
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 smaller requests across days |
FAQ
How do I size TAM without spending records?
Send count=true and limit=1 as query parameters on a search. The answer is meta.total and the call costs 0 records. The leadocean_count_leads MCP tool does the same for people.
Should I count companies or people?
Count companies for TAM and people for reach. TAM is accounts times contract value. The buyer count tells you whether each account has someone you can email.
How fresh is the model?
The dataset is refreshed monthly. Each record also carries its own fetched_at date. Weekly snapshots show when a slice moves.
Can I run this from Claude instead of Python?
Yes for the counts. The hosted MCP server at https://api.leadocean.io/mcp has the free leadocean_count_companies, leadocean_count_leads and leadocean_list_enum_values tools. Paste the totals into slice_count.
What do I read next?
See how to build a lead scoring model to rank the accounts, and the TAM guide for the definitions. To keep a named list current, read how to build a live, self-updating TAM list with an API. More guides are on the blog hub.
Size your TAM for free, then price it in SQL
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →