Clean and qualify a lead list without uploading it anywhere.
Deduplicate, verify emails and phone numbers, fix the capitalisation, then grade every record on whether the person behind it is worth someone's afternoon — all in memory, with no database, no account, and no API key required.
Quickstart · What it does · Lead qualification · Measured results · AI-optional · CLI · Contributing
You buy or export a lead list. It arrives with the same person in it four times,
half the phone numbers missing their country code, RAJESH KUMAR next to
rajesh kumar, a column of N/A and asdf, and a hundred email addresses at
domains that stopped accepting mail two years ago. Cleaning it by hand takes a
day. Uploading it to a SaaS tool means handing your client's contact list to a
third party.
LeadPolish is the third option: it runs on your machine, shows you every change before it makes one, and forgets your file the moment it sends the results back.
git clone https://github.com/devvaham/leadpolish && cd leadpolish
pip install -e ".[server]"
npm --prefix web install && npm --prefix web run build
uvicorn leadpolish.api:appOpen http://127.0.0.1:8000 and click Try the deliberately messy sample — 41 rows of duplicates, dead domains and broken phone numbers, ready to clean.
No account, no key, no config file. If you only want the command line, the second and third commands are optional:
pip install -e .
leadpolish clean your-leads.csv -o cleaned.csv --report audit.pdf
# or try it on the bundled mess first
leadpolish clean leadpolish/data/messy_leads.csv --dry-runEverything below is deterministic. No API key, no network calls beyond an optional DNS lookup you can switch off.
| Deduplication | Exact matches on email and phone first, then fuzzy matching on name, company and contact details together. Every group is scored, explained, and yours to approve or reject one at a time. Blank fields on the record you keep are filled in from the copies removed. |
| Email verification | RFC-correct syntax, then a live DNS check that the domain publishes a mail server. Disposable providers, shared mailboxes (info@, sales@), placeholder domains and misspelt ones (gmial.com) are flagged separately. No mailbox is ever probed — see honesty. |
| Phone verification | Google's libphonenumber, driven by candidate generation rather than a single parse: the cell as typed, 00 swapped for +, as a bare national number, as an international number that lost its +. Output is E.164, with region, line type (mobile / landline / toll-free / VoIP), carrier and time zone. |
| Names & companies | shaun mcdonald → Shaun McDonald, SIOBHAN O'BRIEN → Siobhan O'Brien, MARY-ANNE VAN DER BERG → Mary-Anne van der Berg. Pvt Ltd / Private Limited / Pvt. Ltd. all converge, so they finally deduplicate. |
| Field standardisation | usa, U.S.A. and United States of America become one thing. So do KA and karnataka, 12 church st and 12 Church Street, www.acme.co.in and https://acme.co.in. |
| Blank & junk detection | N/A, asdf, xxx, -, test, keyboard mashing, repeated characters, placeholder emails and empty critical fields — flagged against the row, never silently dropped. |
| Lead qualification | Every delivered record is graded A–D on whether it is worth contacting: can you reach them, can they say yes, is this a real business contact, and do they match the buyer you described. See below. |
| Audit report | A client-ready PDF that opens with a plain-English paragraph, shows real before/after evidence, and pairs every issue with what to do about it. |
1. Upload — drag in a CSV, TSV or Excel file. Bad encodings, semicolon delimiters, BOMs, blank spacer rows and ragged lines are all handled.
2. Map columns — LeadPolish guesses from the headers and the cell
contents, so a column called Column3 full of things with an @ in it is still
the email column. Correct anything it got wrong. Unmapped columns pass through
untouched.
3. Review — nothing is applied until you have seen it. Every proposed change as before → after, every duplicate group with its score and reasoning, every issue with the row it came from.
4. Download the cleaned CSV or Excel file, and the audit report.
Cleaning tells you the data is correct. It does not tell you the list is any good — a spotless file can still be four hundred interns at companies you do not sell to. Every delivered record is scored from four angles:
| Reachability | Is there a verified way to reach this person at all |
| Authority | Seniority read from the job title across eight levels, so a Founder outranks a Manager and "Senior Vice President" is not mistaken for "President" |
| Business identity | Does the email domain agree with the website and the company name. minh.nguyen@vnexpress.net filed under "Minh Corp" is flagged — the address works, it just belongs to a newspaper |
| ICP fit | Do they match the buyer you described |
Out comes a 0–100 score, an A–D grade, a next step (call / email / nurture / do
not work) and the reasoning, all written into the export as LP Lead Score,
LP Grade, LP Action, LP Seniority, LP Department, LP Why and
LP ICP Match.
Describe your buyer — seniority, function, country, company email required — and the same file reorders itself around it. Records that break a rule are still scored and ranked and marked, never silently dropped.
benchmarks/ holds 200 hand-labelled leads and a runner. The labels were
written from what a person would say after ten seconds on each row, before any
of the scoring existed.
python benchmarks/make_dataset.py # regenerate the file
python benchmarks/run.py # measure against it
| Planted duplicates found | 20 / 20 (100% recall) |
| Merges of records that contradict each other | 0 |
| Grades agreeing with the human label | 163 / 163 (100%) |
| Junk records reaching grade A or B | 0 |
| Genuine decision makers buried in C or D | 0 |
| Throughput | 50,000 rows cleaned, deduplicated and graded in ~42s |
The benchmark is synthetic. It shows the tool does what it claims on data whose right answer is known; it cannot prove behaviour on your list. Run it on your own export and read the preview — that is the real test.
Most tools in this space put the cleaning behind an API key. LeadPolish does the opposite: the deterministic engine does all of the cleaning, and the AI layer only picks up what the rules deliberately refused to guess at.
Paste in your own provider key (Anthropic, OpenAI, or any OpenAI-compatible endpoint) and the AI gets:
- the borderline duplicate pairs — the ones that scored just under your threshold, where the matcher would rather ask than guess
- inconsistent job titles and industries — as a list of distinct values, with no row attached
- free-text addresses that need splitting into city, state and postcode
That is all. It cannot override a decision the rules already made, and it can only fill a blank, never overwrite what your file already said.
If the key is missing, wrong, rate limited, or the provider is down, those steps are skipped and the rest of the run is untouched — byte-identical to a no-AI run, with a note saying what was skipped. This is tested, not aspirational.
Decisions made during the preview are replayed on download, so the file you get is the file you reviewed rather than a second, different answer.
- Nothing is stored. No database, no session, no cache, no temp files. The upload is held in memory for the duration of one request and dropped when the response is sent. Every response says so in a header.
- Nothing leaves the machine except two things you control: an optional DNS lookup to check whether an email domain accepts mail (togglable), and the optional AI calls (off unless you paste in a key).
- The AI never sees the file. It sees two records when adjudicating a duplicate, or a column's distinct values with no row attached.
- No webfonts, no analytics, no CDN. The UI uses system fonts on purpose — a webfont request would hand every visitor's IP address to a third party.
- Your API key is never persisted. It lives in the browser tab, is sent with the request so the server can call your provider, and is discarded with it.
Because there is no storage, there is also nothing to delete, no data-retention policy to write and no breach surface. Closing the tab is the entire cleanup.
An audit report that overstates its confidence is worse than no report:
- A domain that accepts mail is not proof a mailbox exists. LeadPolish checks MX records. It never sends a test message or probes an address — that gets your IP blocklisted and lies to you on catch-all domains anyway.
- Line type comes from published numbering plans, not a live carrier lookup. A number ported between operators still reports its original allocation, and in countries where mobile and fixed ranges overlap you get "mobile or landline".
- Fuzzy matching is a judgement call. LeadPolish shows its score and its reasoning and lets you overrule it. It will never merge two records that have different email addresses and different phone numbers on a similar name alone.
leadpolish inspect leads.csv # what did it detect?
leadpolish clean leads.csv \
-o cleaned.xlsx \ # .csv or .xlsx
--report audit.pdf \ # .pdf or .html
--changes changes.csv \ # every before/after
--audit audit.json \ # machine-readable summary
--region IN \ # default country for bare phone numbers
--threshold 88 \ # duplicate sensitivity, 50-100
--map email="Work Email" \ # override a column guess
--skip dedupe \ # turn an operation off
--no-mx # fully offline--dry-run reports without writing the cleaned file.
docker build -t leadpolish . && docker run -p 8000:8000 leadpolishThere is no database to provision, so the free tier of anything is enough.
A render.yaml blueprint is included for one-click deploys; Fly, Hugging Face
Spaces and plain Docker are covered in docs/DEPLOY.md, along
with the settings worth tightening once the URL is public.
Note that LeadPolish has no accounts by design — anyone with the URL can use it. That is fine for a tool that stores nothing, but put a password in front of it before sharing the link widely.
Everything is optional. Copy .env.example if you want to
change the defaults.
| Variable | Default | What it does |
|---|---|---|
LEADPOLISH_MAX_UPLOAD_MB |
50 |
Largest file the server accepts |
LEADPOLISH_MAX_ROWS |
100000 |
Rows read from a single file |
LEADPOLISH_PREVIEW_ROWS |
500 |
Rows in the preview table |
LEADPOLISH_MAX_CHANGES |
5000 |
Changes returned to the browser |
LEADPOLISH_ALLOW_ORIGINS |
* |
CORS origins |
There is deliberately no API key setting: keys are supplied per-request by the person using the tool, never stored on the server.
pip install -e ".[server,dev]"
pytest # 245 tests
npm --prefix web run dev # UI on :5173, proxying to :8000
npm --prefix web run e2e # browser walk-through of the whole flow
npm --prefix web run e2e:ai # the same, with the AI layer onBoth browser walk-throughs expect a server on :8137
(uvicorn leadpolish.api:app --port 8137) and Chromium; set CHROMIUM_PATH if
Playwright cannot find it. e2e:ai additionally expects a stand-in provider on
:8199 — python web/mock_provider.py 8199 speaks both the Anthropic and the
OpenAI wire format, so the AI path is testable end to end without spending
anything or handing a test file to a real provider.
leadpolish/
├── tables.py file in, DataFrame out: encodings, delimiters, Excel
├── mapping.py which column is which
├── reference.py the lookup tables — countries, suffixes, junk tokens
├── text.py names, companies, titles, addresses, locations
├── emails.py syntax, MX, disposable, role, typo detection
├── phones.py libphonenumber with candidate generation
├── junk.py placeholder and keyboard-mash detection
├── dedupe.py exact pass, then blocked fuzzy scoring
├── pipeline.py orchestration, change log, audit
├── report.py the client-ready HTML/PDF report
├── api.py stateless FastAPI endpoints
├── cli.py inspect and clean
└── ai/ the optional layer: one provider interface, three tasks
web/ React + Tailwind, built into leadpolish/static
Adding a cleaning rule usually means editing a literal in reference.py. See
CONTRIBUTING.md.
MIT. Use it, sell what you make with it, no strings.



