A lead scoring model is a short list of rules that turns each lead into one number. This guide builds the fit half: who the person is and whether you can reach them. It runs on real filters, real counts and 60 lines of Python.
TL;DR
- You will define a points table, size each tier for free, score a sample, then backtest the weights against your closed-won deals.
- It takes about 45 minutes and spends about 155 LeadOcean records on the Free plan. Counts cost nothing.
- Fit and reach are scored from LeadOcean data. Engagement (page views, replies) stays in your CRM. See what lead scoring is for the split.
Prerequisites
- A LeadOcean API key in
$LEADOCEAN_API_KEY. The Free plan works: 1,000 records for life, 5 requests a second. - Python 3.10+ with
requestsinstalled. - Optional for Step 5: a CSV export of your closed-won and closed-lost deals with title, company size and country.
- Optional: the LeadOcean MCP server at
https://api.leadocean.io/mcp, if you want to run the counts from Claude instead of curl.
Step 1: Write the points table
Start with a table you can say out loud. Two fit signals and two reach signals are enough to start: job level, company size, a verified email and a phone on file.
| Signal | Value | Points |
|---|---|---|
| Job level | VP | 40 |
| Job level | Director | 25 |
| Job level | Manager | 10 |
| Company size | 51-200 | 30 |
| Company size | 201-500 | 15 |
| Reach | email_status is verified | 20 |
| Reach | has_phone is true | 10 |
The max is 100. Level and size come from the query you run. Reach comes from the response, so it is scored per person.
One rule worth keeping: points for things you can verify, not things you hope. A VP title is a field. "Seems interested" is not.
Step 2: Size every tier for free
Count before you pull. A count spends no records, so run it as often as you like. Send count=true as a query parameter and "limit": 1 in the body.
curl -s -X POST "https://api.leadocean.io/v1/people/search?count=true" \
-H "x-api-key: $LEADOCEAN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"jobLevel": ["VP"],
"jobFunction": ["Sales & Business Development"],
"employeeRange": ["51-200"],
"country": ["US"],
"limit": 1
}'meta.total is the match count, capped at 100,000. The same filters work in the leadocean_count_leads MCP tool, which also returns distinctCompanies. For company-side sizing, GET /v1/companies/count is free too.
If a tier holds fewer than 100 people, merge it with its neighbour. A tier you can never fill is not a tier.
Step 3: Pull a sample per tier
Each cell of the level and size grid is its own query. The cells never overlap, so no person is scored twice and you need no dedupe step. Six cells at 25 rows is 150 records.
import os, time, requests
BASE = "https://api.leadocean.io"
HEADERS = {"x-api-key": os.environ["LEADOCEAN_API_KEY"]}
LEVEL_PTS = {"VP": 40, "Director": 25, "Manager": 10}
SIZE_PTS = {"51-200": 30, "201-500": 15}
def pull(level, size, limit=25):
body = {
"jobLevel": [level],
"jobFunction": ["Sales & Business Development"],
"employeeRange": [size],
"country": ["US"],
"limit": limit,
}
r = requests.post(f"{BASE}/v1/people/search", json=body, headers=HEADERS)
r.raise_for_status()
return r.json()["data"]
rows = []
for level in LEVEL_PTS:
for size in SIZE_PTS:
for person in pull(level, size):
rows.append((level, size, person))
time.sleep(0.25) # stay under 5 requests a second on FreeStep 4: Score each row
Add the reach points from contact_data. Both fields come back on every row, whether or not you reveal the email.
def score(level, size, person):
contact = person.get("contact_data", {})
pts = LEVEL_PTS[level] + SIZE_PTS[size]
if contact.get("email_status") == "verified":
pts += 20
if contact.get("has_phone"):
pts += 10
return pts
scored = sorted(
((score(l, s, p), l, s, p) for l, s, p in rows),
key=lambda x: x[0],
reverse=True,
)
for pts, level, size, p in scored[:5]:
c = p["contact_data"]
print(pts, level, size, c.get("email_status"), c.get("has_phone"))Set your cut-offs on the sorted list, not in the abstract. One split to start from: A at 80 and above, B at 50 to 79, and C below.
To go deeper on the top rows, enrich them with POST /v1/people/enrich, sending each row's meta.source_ids.person_id as person_id. Since October 2026 enrich also returns career history in resume_data: past roles, education and certifications. A "joined this role in the last year" bonus is easy to add from contact_current_experiences.
Step 5: Backtest the weights
A points table is a hypothesis. Test it. Take 50 or more closed-won and 50 or more closed-lost deals, score each contact with the same table, and compare.
import pandas as pd
from sklearn.linear_model import LogisticRegression
df = pd.read_csv("deals.csv") # columns: level_pts, size_pts, verified, has_phone, won
X = df[["level_pts", "size_pts", "verified", "has_phone"]]
model = LogisticRegression().fit(X, df["won"])
print(dict(zip(X.columns, model.coef_[0].round(2))))Read the coefficients as relative weight. If size_pts carries more than level_pts, your hand-set table is upside down. Rescale the points to match, rerun, and keep the table if the top band wins more often than the bottom.
With fewer than about 100 closed deals, keep the hand-set table and revisit in a quarter.
What you get
The free count for the Step 2 filters on 21 October 2026, run through the leadocean_count_leads MCP tool with facets: false:
{
"total": 18567,
"totalIsExact": true,
"totalAll": 60456,
"distinctCompanies": 12192,
"credits": 0
}total counts people with a mailable address (verified, catch_all_valid or catch_all). totalAll is the same filters with no email condition. Adding industry: ["Software Development"] to the same query returned 1,649 mailable out of 4,141 (21 October 2026, also 0 records).
That is the shape of your funnel: 60,456 people match the fit rules, 18,567 can be emailed today, and your score decides which of those go first.
Each scored row from Step 4 looks like this. This is the response shape from the API reference, with placeholder values:
{
"profile_data": { "profile_full_name": "Jane Doe" },
"contact_data": {
"has_email": true,
"has_phone": false,
"email_status": "verified",
"email_type": "work",
"contact_current_experiences": [
{ "job_title": "VP Sales", "company_name": "Acme", "company_domain": "acme.com" }
]
},
"meta": { "source_ids": { "person_id": "1234567" } }
}Troubleshooting
| Error | Cause | Fix |
|---|---|---|
| 401 | Missing or wrong x-api-key header | Check $LEADOCEAN_API_KEY is exported and the key is active in app.leadocean.io. |
| 429 | Above the plan's rate limit (5 requests a second on Free, 200 on Agency) | Honour the Retry-After header and keep the time.sleep in the loop. |
| 402 | Records spent (1,000 for life on Free) | Check GET /v1/usage, then move to Solo at $97 a month. |
Empty data | A filter value is not an exact enum string | List valid values with GET /v1/enums/job_level or leadocean_list_enum_values. Use VP, not vp. |
meta.depthCapped is true | One search pages to 10,000 rows | Narrow the filters and run several smaller searches. |
| Export refused | Over 50,000 rows a request or 500,000 a day | Split the export by country or level. Bulk CSV export is not on Free. |
FAQ
How many signals should a lead scoring model have?
Few enough that anyone can explain why a lead scored 73. This guide starts with two fit signals and two reach signals. Add industry as a third fit signal once the first table holds up.
Should I use machine learning instead of points?
Not at first. A points table is readable, and sales can argue with it. Use a regression as a check on the weights (Step 5), once you have about 100 closed deals.
Where do engagement signals go?
In your CRM or marketing tool, next to the fit score. Keep the two scores separate and route on both. The HubSpot setup guide shows one way to do that.
Does the score use the same data as my TAM?
It can. Size the market first with a TAM model in SQL, then score inside it. Plans and limits are on the pricing page.
How often should I rescore?
Rescore when the data moves. The dataset refreshes monthly, and each record carries a fetched_at date. A monthly rescore is enough for fit.
More guides are on the blog hub.
Score your first 150 leads on a free API key
Free to start. No credit card. 1,000 records to spend whenever you like.
Get your free API key →