How to Build a Lead List of 1,000 Qualified Prospects (Without Buying One)
Bought lead lists are stale the day they arrive. Here's how to build your own — scored by real buying signals, and filtered by disqualifiers most lists ignore — with working Python.
Every sales team has lived this. You pay four or five figures for a lead list. It arrives as a CSV. Half the contacts have moved jobs, a third of the companies aren't remotely a fit, and — worst of all — your competitors bought the exact same list from the exact same vendor. You're all emailing the same tired inboxes on the same Tuesday morning.
The alternative isn't buying a better list. It's building your own from live web data, so it's current, fitted to your actual ICP, and scored by real buying signals nobody else is watching.
This post walks through exactly that, end to end, with working Python. Our example: a fictional B2B company, DeployIQ, that sells developer tooling. Their ideal customer is a 50–500 person software company actively growing its engineering team — because teams hiring engineers are the teams buying developer tools.
Why most lead lists are wrong
Bought lists measure one thing: fit. Industry, headcount, geography. Fit tells you who could buy. It says nothing about who's ready to buy right now — and nothing at all about who you should actively skip.
An accurate list needs three things a CSV from a vendor will never give you:
- Intent signals — evidence a company is in motion right now (hiring, funding, new leadership).
- Fit qualification — read from their actual website, not a stale database field.
- Disqualifiers — the negative signals that rule companies out. This is the step almost everyone skips, and it's where most of the accuracy comes from.
So we'll build in four layers:
| Layer | Question it answers | Sources |
|---|---|---|
| 1. Universe | Who could possibly be a fit? | Company directories |
| 2. Intent | Who's in motion right now? | Job boards, SEC filings, news |
| 3. Fit | Are they really our ICP? | Company site, pricing page |
| 4. Disqualifiers | Who should we skip? | Layoff trackers, news, tech stack |
One setup note before the code: AI extraction is a POST request, and you should set a client timeout of at least 70 seconds because ScrapeUp retries failed attempts internally for up to 60.
import requests
API_KEY = "your_scrapeup_key"
ENDPOINT = "https://api.scrapeup.com/"
TIMEOUT = 70 # docs recommend 70s+ to allow for internal retriesLayer 1: Build the universe from real directories
Start broad. These are the public directories worth pointing at, depending on who you sell to:
- Y Combinator's company directory — excellent for venture-backed startups. Infinite scroll, so pair
renderwithlazy_load. - G2 and Capterra category pages — software companies organized by exactly the category you sell into.
- Clutch.co — agencies and service providers, cleanly paginated.
- Product Hunt — newer products and the teams behind them.
- SEC EDGAR and OpenCorporates — public records, ideal when you need verified legal entities rather than marketing pages.
- Industry association member directories and chamber of commerce listings — underrated, low-competition, and rarely protected.
The key parameter is extract: hand it a JSON object where each key is a field you want and each value describes that field in plain English.
def scrape_directory_page(url: str) -> list:
resp = requests.post(ENDPOINT, json={
"api_key": API_KEY,
"url": url,
"render": True, # most directory listings are JS-rendered
"extract": {
"companies": "array of every company on this page, each with: "
"name, website URL, industry, employee_count as an "
"integer if shown, and headquarters location"
},
"extract_model": "balanced",
}, timeout=TIMEOUT)
data = resp.json()
if data.get("extracted") is None:
print(f" extraction failed: {data.get('extraction_error')}")
return []
return data["extracted"]["companies"]
universe = []
for page in range(1, 21):
url = f"https://www.g2.com/categories/devops-platforms?page={page}"
found = scrape_directory_page(url)
universe.extend(found)
print(f"page {page}: +{len(found)} companies")
print(f"Universe: {len(universe)} companies")Note the failure check. When extraction can't find anything, ScrapeUp returns extracted: null plus an extraction_error — and you are not charged the extraction surcharge.
Cost: 20 pages × (10 render + 8 balanced) = 360 credits.
Layer 2a: Hiring signals — scrape the ATS, not the aggregator
This is the single highest-value tip in this post, so here it is plainly: don't scrape job aggregators when you can scrape the applicant tracking system directly.
Most companies host their careers page on a handful of ATS platforms, at completely predictable URLs:
boards.greenhouse.io/{company}jobs.lever.co/{company}jobs.ashbyhq.com/{company}{company}.recruitee.comandapply.workable.com/{company}
Three reasons this beats the aggregators. It's the company's own public careers page, so no login is involved. Every company on a given ATS uses an identical template, so one extraction schema works across thousands of companies. And these pages are lightly protected, so you usually don't need rendering or unlocking at all — about 6 credits per company versus 50+ for a heavily defended aggregator.
ATS_PATTERNS = [
"https://boards.greenhouse.io/{slug}",
"https://jobs.lever.co/{slug}",
"https://jobs.ashbyhq.com/{slug}",
]
HIRING_SCHEMA = {
"open_roles": "total number of open job postings, as an integer",
"engineering_roles": "number of postings that are software engineering "
"or developer roles, as an integer",
"titles": "array of all job titles listed",
"tech_mentioned": "array of technologies, languages, or tools named "
"anywhere in the postings",
"locations": "array of office locations mentioned",
}
def get_hiring_signal(company_slug: str) -> dict:
for pattern in ATS_PATTERNS:
resp = requests.post(ENDPOINT, json={
"api_key": API_KEY,
"url": pattern.format(slug=company_slug),
"extract": HIRING_SCHEMA,
"extract_model": "fast", # simple schema, high volume
}, timeout=TIMEOUT)
data = resp.json()
if data.get("extracted") and data["extracted"].get("open_roles"):
return data["extracted"] # found their ATS, stop looking
return {} # no public ATS board foundNotice tech_mentioned. Job descriptions list the stack a team actually uses — which means you get a free tech-stack signal out of a request you were making anyway. No separate tool needed.
Other hiring sources worth knowing:
- Hacker News "Who is Hiring" (the monthly thread) — plain HTML, no JavaScript, no anti-bot. Roughly 6 credits for hundreds of hiring companies. The best value signal on the internet.
- Wellfound and Built In — startup-focused, moderately protected.
- Indeed, Glassdoor, and LinkedIn's public job listings — the widest coverage, but heavily defended. These need
unlock=true(50x credits) and sometimesrender=trueon top. Use them to fill gaps, not as your primary source. Important: stick to listing pages that are visible without signing in — data behind a login is off-limits (more on that below).
# Aggregator fallback — only for companies with no public ATS board
def get_hiring_signal_aggregator(company_name: str) -> dict:
resp = requests.post(ENDPOINT, json={
"api_key": API_KEY,
"url": f"https://www.indeed.com/jobs?q=company:{company_name}",
"unlock": True, # heavy anti-bot: 50x credits
"render": True,
"lazy_load": True, # scroll to trigger lazy-loaded listings
"extract": HIRING_SCHEMA,
"extract_model": "fast",
}, timeout=TIMEOUT)
return resp.json().get("extracted") or {}Cost: 1,000 companies × (1 base + 5 fast) = 6,000 credits via ATS. The same coverage through unlocked aggregators would cost 55,000 credits — or 80,000 if those pages also need rendering.
Layer 2b: Funding signals — free, public, and underused
A company that just raised has budget and a mandate to spend it. Most teams check Crunchbase; far fewer check the source it's derived from.
When a US company raises privately, it files a Form D with the SEC. It's public, free, and often lands before the funding press cycle. EDGAR's full-text search is a hash-routed single-page app, which is exactly what ScrapeUp's hash parameter is for.
def get_funding_signal(company_name: str) -> dict:
resp = requests.post(ENDPOINT, json={
"api_key": API_KEY,
"url": "https://www.sec.gov/edgar/search/",
"render": True,
"hash": f"/q=%22{company_name}%22&forms=D", # SPA hash route
"extract": {
"filings": "array of Form D filings shown, each with the filing "
"date, company name, and form type",
"most_recent_date": "date of the most recent filing, YYYY-MM-DD",
},
"extract_model": "balanced",
}, timeout=TIMEOUT)
return resp.json().get("extracted") or {}A Form D filed in the last six months is one of the strongest buying signals available, and it costs about 18 credits to check.
Layer 2c: Leadership changes and news
A new VP of Engineering or CTO is a classic trigger event: new leaders re-evaluate tooling in their first two quarters. Company newsrooms and press pages are the cheapest place to catch this, and the same request can pick up expansion news, product launches, and — critically for Layer 4 — bad news.
def get_news_signal(company_website: str) -> dict:
resp = requests.post(ENDPOINT, json={
"api_key": API_KEY,
"url": f"{company_website.rstrip('/')}/blog",
"render": True,
"extract": {
"recent_posts": "array of the 10 most recent posts with "
"title and date",
"leadership_changes": "any new executive hires mentioned, with "
"the role and name",
"expansion_news": "any new offices, markets, or major "
"customer wins mentioned",
"negative_news": "any layoffs, restructuring, downsizing, "
"or acquisition news mentioned",
},
"extract_model": "balanced",
}, timeout=TIMEOUT)
return resp.json().get("extracted") or {}Layer 3: Qualify from their own website
Now read the company's actual site. Two pages matter: the homepage tells you what they sell and who they sell to; the pricing page tells you their deal size and segment, which is how you predict whether they're worth a rep's time at all.
Here we use extract_prompt — a plain-English instruction rather than a rigid schema — because homepages vary wildly. (extract and extract_prompt are mutually exclusive; use one or the other.)
def qualify_company(website: str) -> dict:
resp = requests.post(ENDPOINT, json={
"api_key": API_KEY,
"url": website,
"render": True,
"extract_prompt": (
"From this company's website, return JSON with: what they sell "
"in one sentence; their primary customer type (B2B or B2C); "
"the market segment they target (SMB, mid-market, or "
"enterprise); any engineering or developer tools they publicly "
"mention using; and the best general contact email or contact "
"page URL."
),
"extract_model": "precision", # messy, varied pages
}, timeout=TIMEOUT)
return resp.json().get("extracted") or {}
def get_pricing_profile(website: str) -> dict:
resp = requests.post(ENDPOINT, json={
"api_key": API_KEY,
"url": f"{website.rstrip('/')}/pricing",
"render": True,
"extract": {
"lowest_plan_price": "lowest listed monthly price as a number",
"highest_plan_price": "highest listed monthly price as a number",
"has_enterprise_tier": "true if a custom or enterprise tier exists",
"free_tier": "true if a free plan or trial is offered",
},
"extract_model": "balanced",
}, timeout=TIMEOUT)
return resp.json().get("extracted") or {}Use precision on the homepage. This is the step where a wrong answer wastes a rep's time, and it's worth a couple of extra credits per company.
Layer 4: Disqualifiers — the step everyone skips
Here's where accuracy really comes from. A lead list that only adds points will happily hand your reps companies that just laid off 20% of staff, or that signed a three-year contract with your biggest competitor last quarter. Both are guaranteed wasted calls.
Three disqualifiers worth checking:
def get_disqualifiers(company_name: str, website: str, hiring: dict) -> dict:
flags = {}
# 1. Recent layoffs — public trackers are plain, fast pages
resp = requests.post(ENDPOINT, json={
"api_key": API_KEY,
"url": f"https://layoffs.fyi/?s={company_name.replace(' ', '+')}",
"render": True,
"extract": {
"layoff_found": "true if this company appears in a layoff record",
"layoff_date": "date of the most recent layoff, YYYY-MM-DD",
"employees_laid_off": "number of employees affected, as integer",
},
"extract_model": "fast",
}, timeout=TIMEOUT)
flags["layoffs"] = resp.json().get("extracted") or {}
# 2. Competitor lock-in — read it free from the job postings you already have
COMPETITORS = {"jenkins", "circleci", "travis ci", "teamcity"}
stack = {t.lower() for t in (hiring.get("tech_mentioned") or [])}
flags["uses_competitor"] = sorted(stack & COMPETITORS)
return flagsThe competitor check costs nothing extra — you already pulled tech_mentioned from the ATS request in Layer 2a. That's the payoff for designing your extraction schema thoughtfully the first time.
Scoring: add points, and subtract them
Now combine everything. The important part is that this function can return a negative score, which is how bad-fit companies get pushed off the bottom of the list instead of quietly sitting in the middle of it.
from datetime import datetime, timedelta
def score(company, hiring, funding, news, profile, pricing, flags) -> int:
pts = 0
# --- FIT ---
size = company.get("employee_count") or 0
if 50 <= size <= 500: pts += 25
elif 20 <= size < 50: pts += 10
if (profile.get("customer_type") or "").upper() == "B2B":
pts += 10
if (profile.get("segment") or "").lower() in ("mid-market", "enterprise"):
pts += 10
if pricing.get("has_enterprise_tier"):
pts += 5 # sells big deals, so can afford ours
# --- INTENT ---
eng = hiring.get("engineering_roles") or 0
if eng >= 5: pts += 30
elif eng >= 2: pts += 20
elif eng >= 1: pts += 10
recent_filing = funding.get("most_recent_date")
if recent_filing:
filed = datetime.fromisoformat(recent_filing)
if filed > datetime.now() - timedelta(days=180):
pts += 30 # raised in the last 6 months
if news.get("leadership_changes"):
pts += 20 # new leader = tooling re-evaluation
if news.get("expansion_news"):
pts += 10
# --- DISQUALIFIERS ---
layoffs = flags.get("layoffs") or {}
if layoffs.get("layoff_found"):
laid_off = layoffs.get("layoff_date")
if laid_off and datetime.fromisoformat(laid_off) > datetime.now() - timedelta(days=180):
pts -= 50 # hiring freeze territory
if flags.get("uses_competitor"):
pts -= 25 # entrenched with a rival
if news.get("negative_news"):
pts -= 20
return ptsThen assemble, filter, and export. Note the ordering — cheap signals run first so you never spend expensive precision credits on a company you were going to reject anyway.
import csv
rows = []
for c in universe:
slug = c["name"].lower().replace(" ", "")
hiring = get_hiring_signal(slug) # cheap: ~6 credits
if (hiring.get("engineering_roles") or 0) < 1:
continue # no intent, stop here
funding = get_funding_signal(c["name"])
news = get_news_signal(c["website"])
flags = get_disqualifiers(c["name"], c["website"], hiring)
if (flags.get("layoffs") or {}).get("layoff_found"):
continue # hard skip
profile = qualify_company(c["website"]) # expensive: precision
pricing = get_pricing_profile(c["website"])
rows.append({
"company": c["name"],
"website": c["website"],
"employees": c.get("employee_count"),
"eng_roles": hiring.get("engineering_roles"),
"stack": ", ".join(hiring.get("tech_mentioned") or []),
"funded": funding.get("most_recent_date"),
"new_leaders": news.get("leadership_changes"),
"segment": profile.get("segment"),
"contact": profile.get("contact"),
"competitor": ", ".join(flags.get("uses_competitor") or []),
"score": score(c, hiring, funding, news, profile, pricing, flags),
})
rows = [r for r in rows if r["score"] > 0] # drop net-negative leads
rows.sort(key=lambda r: r["score"], reverse=True)
with open("qualified_leads.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=rows[0].keys())
w.writeheader()
w.writerows(rows)
print(f"{len(rows)} qualified leads. Top score: {rows[0]['score']}")Scaling it without tripping over yourself
Respect your concurrency limit. Exceed it and the API returns a 429. Run in parallel, but cap the pool at your plan's limit — 5 on Lite Basic, 50 on Professional Starter, 300 on Enterprise Starter.
from concurrent.futures import ThreadPoolExecutor
MAX_CONCURRENT = 5 # match your plan
with ThreadPoolExecutor(max_workers=MAX_CONCURRENT) as pool:
signals = list(pool.map(get_hiring_signal, slugs))Watch your budget. The /account endpoint shows where you stand, and every response includes credits_used.
acct = requests.get(
"https://api.scrapeup.com/account",
params={"api_key": API_KEY},
timeout=TIMEOUT,
).json()
print(f"Used {acct['requestCount']} of {acct['requestLimit']} requests")
print(f"Concurrency: {acct['concurrentRequests']}/{acct['concurrencyLimit']}")What this actually costs
Credits are additive: base request, plus a multiplier for rendering or unlocking, plus a surcharge for the extraction tier.
| Layer | Volume | Credits each | Total |
|---|---|---|---|
| 1. Directory pages | 20 | 10 render + 8 balanced = 18 | 360 |
| 2a. Hiring (ATS) | 1,000 | 1 base + 5 fast = 6 | 6,000 |
| 2b. Funding (EDGAR) | 300 | 10 render + 8 balanced = 18 | 5,400 |
| 2c. News / leadership | 300 | 10 render + 8 balanced = 18 | 5,400 |
| 4. Layoff check | 300 | 10 render + 5 fast = 15 | 4,500 |
| 3. Site + pricing | 150 × 2 | 10 render + 10 precision = 20 | 6,000 |
| Total | 27,660 | ||
That's a fully signal-scored, disqualifier-filtered pipeline for under 28,000 credits — roughly one month on the Lite plan, and just past the 25,000 free credits you start with.
Three easy ways to cut it substantially: drop render wherever the page doesn't need JavaScript (ATS boards, Hacker News, and many directories don't — that's 10 credits back each time); use fast instead of precision on well-structured pages; and narrow Layer 2b and 2c to only the companies that already cleared the hiring filter. Applied together, those bring the same pipeline in around 16,500 credits — back inside the free tier.
Sources to avoid, and staying on the right side of the line
A lead-gen pipeline is only an asset if it doesn't create liability. Four rules worth internalizing:
- Never scrape behind a login. Public job listings are fine; anything requiring you to sign in is not. Defeating authentication can constitute unauthorized access under computer-fraud law, and it's prohibited under ScrapeUp's Acceptable Use Policy. This is the single most important line — and it's why the ATS approach above is both cheaper and safer.
- Target companies, not individuals. Company-level facts — headcount, open roles, funding, tech stack — carry far less regulatory weight than personal data. Harvesting individuals' details pulls you into GDPR and CCPA territory fast. Prefer a role-based contact route (a contact page, a general inbox) over building a database of named people.
- Don't build this to spam. The point of scoring and disqualifying is to send fewer, better-targeted messages. Unsolicited bulk messaging violates most acceptable-use policies, including ScrapeUp's, and your outreach still needs to comply with rules like CAN-SPAM.
- Be a polite visitor. Respect rate limits, don't hammer a host, and honor what sites ask of automated traffic. Your pipeline should be invisible to the sites it reads.
None of this is a reason to avoid building the list. It's the difference between a durable competitive asset and a problem you inherit later.
Why this beats a bought list
- It's current. You generated it today, not last quarter.
- It's yours. Your ICP, your filters, your scoring — not a vendor's generic segment.
- It's prioritized by intent. Hiring, funding, and leadership changes rank the list, so reps start at the top instead of dialing alphabetically.
- It's filtered by disqualifiers. Companies in layoffs or locked in with a competitor never reach a rep. No bought list does this.
- It's repeatable. Re-run the signal layers monthly and you have a living pipeline.
- Nobody else has it. That's the whole point.
The same layered pattern adapts to almost any market. Selling to restaurants? Directory + new-location permits + menu pages, minus recent closures. Selling to e-commerce brands? Directory + hiring + ad-tech stack, minus anyone who just filed for bankruptcy. The sources change; the shape doesn't.
FAQ
Is it legal to scrape company data for lead generation?
Collecting publicly available business information — company names, websites, public job postings, public filings — is a long-standing and common practice when done responsibly: at a reasonable rate, without defeating logins, and within each site's rules. Personal data is regulated separately under GDPR, CCPA and similar laws, and outreach must comply with anti-spam rules such as CAN-SPAM. For a commercial program at scale, talk to your legal counsel.
Why scrape ATS boards instead of Indeed or LinkedIn?
Three reasons: they're the company's own public careers page with no login involved; every company on the same ATS shares an identical template, so one extraction schema works across thousands of companies; and they're lightly protected, costing around 6 credits versus 50+ for an unlocked aggregator. Use aggregators only to fill coverage gaps.
Should I use `extract` or `extract_prompt`?
Use extract when you know the exact fields you want — you get predictable, consistent keys, which is what a CSV or database needs. Use extract_prompt when content is messy or variable and you'd rather let the AI choose the structure. They're mutually exclusive, and both require a POST request.
Which extraction model should I use?
Start with fast (+5 credits) for simple, well-structured pages at volume. Use balanced (+8) for moderately complex pages, precision (+10) where accuracy matters more than cost, and ultra (+15) for genuinely difficult multi-step reasoning.
How often should I refresh the list?
Refresh the intent layers — hiring, funding, news — weekly or monthly, since companies move in and out of buying mode constantly. The directory universe changes slowly and can be rebuilt quarterly. Disqualifiers are worth re-checking before any big outreach push.
Ready to build your own list? Start with 25,000 free credits — enough to run this entire pipeline. Full parameter reference is in the API documentation.