Skip to content

Import accounts

There are three ways to get accounts into LeadHunter. All three check new accounts against your existing database before creating them, but each acts on a match a little differently; the duplicate rules list what each path does. Whatever slips through is resolved by merging in Find duplicates, which keeps every unique field from every duplicate.

Pick the path that matches the source:

SourceUseWhere
One-off entryQuick add by name, URL, or search queryAccounts → Add account → From URL
Existing list (CSV / XLSX)Bring over a sheet you already maintainAccounts → Import
New market discoverySweep a geo/category in one passAccounts → Find new accounts → New Research → Google Maps API

Add account, Import and Find new accounts are buttons at the top of the Accounts page. On a narrow window, Import and Find new accounts move into the More menu next to them.

Best for ad-hoc additions — “there’s this one bike shop I want to track, can you find it for me?”

From Accounts → Add account, choose From URL (Look up business) and paste any of:

  • A website URL (https://acme.com) — scraped + enriched.
  • A bare domain (acme.com) — LeadHunter prepends https://.
  • A Google Maps URL — pulls address, phone, hours, rating, place id.
  • A plain-text query (“bike shops in Berlin”) — Google Maps text search; the top match is used. Requires Google Maps to be configured.

The app offers Quick lookup (the default, also what Enter runs) and Deep research as two buttons next to the field; the API also accepts a third mode, none:

ModeWhat runsWhen to pick
None (API only)Plain scrape; no LLM.You’re importing a vetted URL and don’t want to spend AI budget.
Quick lookup (default)AI extracts business name, sector, language, key fields you didn’t provide.Most of the time.
Deep researchQuick + multi-page crawl (about / team / staff / contact pages) + decision-maker contact extraction.When you want named contacts pre-filled, not just the company.

Deep mode is heavier (more page fetches, more tokens) but produces an account that’s already populated with one or two contacts with names, titles, and (when discoverable) emails. Use it for high-value accounts where you’d otherwise hand-research the buying group.

Best for bringing over a list you already maintain — your CRM export, a trade-show booth scan, an industry directory you bought, etc. Accounts → Import runs a three-step wizard.

Drop a .csv or .xlsx file of up to 10 MB and 200,000 rows (split bigger files; save an old .xls workbook as .xlsx or CSV first). LeadHunter parses the headers and shows you a sample of the first few rows so you can confirm parsing worked (Excel files with ragged rows — rows that have fewer cells than the header — are handled correctly).

A few file quirks are fixed for you on upload:

  • Excel’s “CSV UTF-8” export starts with an invisible marker that used to stick to the first column name; it’s removed, so a first column called Name is recognised as Name and suggested for the right field.
  • Blank column headers get a name from their position (Column 4), and repeated headers are numbered (Phone, Phone (2)), so two columns with the same name no longer collapse into one and lose data. Map each one on its own in the next step.

This is the step where you tell LeadHunter what each column means. It opens already mapped from the column headers: Business Name, Company or Nombre land on Account name, Ciudad or Town on City, Teléfono or Tel on Phone, Zip Code or CP on Postal code, and so on. Case, accents and spacing don’t matter (e-mail, E Mail and EMAIL are the same header). Columns mapped this way carry a small header tag, and a line at the top says how many columns were mapped (“5 of 7 columns mapped from their headers. 2 left on Skip.”). Each row shows up to three real values from the column — taken from the first 200 rows, so a sparse column (socials, notes) still shows what it holds.

Columns nobody recognised stay on Skip. For those, Suggest the rest with AI asks the AI to map them from their names and sample values. It only fills columns that are still on Skip and that you haven’t changed, never reuses a field another column already has, and marks each suggestion high / med / low. You don’t need it when the headers were mapped; the button costs one AI call.

The fields a column can map to:

Account name, Address, City, State / province / region, Postal code, Country, Phone, Email, Website, Google Place ID, Latitude, Longitude, Business type, Specialization, Rating, Language, Lifecycle stage, Acquisition channel, Contract end date, External ID, Notes, LinkedIn, Instagram, X (Twitter), Facebook, YouTube, TikTok, Year founded, Company size, Relationship types, Audience size, Partner stage.

Aliases — state, province, and region all resolve to state_province_region, so you don’t have to rename your column.

Skip — set the target to skip for columns you don’t want imported. Skipped columns can still be preserved as JSON (see Save extras below).

External ID (imported_id) — map your source’s stable row id here. If your file has a column like Station UUID, CRM Record ID, Customer ID, or any other identifier that uniquely identifies a row in the source dataset, map it to imported_id. Re-importing the same file (or a new export from the same source) will then resolve every match via that id at the highest confidence — even when name + city don’t agree, or are missing entirely. Without an imported_id, only an identical name + city (or a Google Place ID) stops a row from being imported again, which misses matches when the file is sparse (radio-station directories, form sign-up exports, etc.). Headers mapped to it automatically: ID, External ID, OriginalID, Imported ID.

Account name is mandatory — the import refuses to start without a column mapped to it.

Already imported this file? You’ll see a warning.

Section titled “Already imported this file? You’ll see a warning.”

If the bytes of the file you just uploaded exactly match a previous import in this project, the mapping step shows a yellow banner at the top: “You’ve already imported this exact file” — with the prior import date, who ran it, how many rows landed, and how many of those accounts still exist. Cancel if you uploaded the wrong file; proceed if the warning is just informational (you re-uploaded on purpose). Either way, rows that match an existing account on Google Place ID, imported ID or name + city are skipped.

Step 3: Run (and the options worth knowing)

Section titled “Step 3: Run (and the options worth knowing)”

A few options control how the import behaves:

  • Static values — constants applied to every row. Useful when the file doesn’t carry a column for it but the value is the same for the whole batch. Examples: country=Spain for a Spanish trade-show export, language=Catalan for a regional directory, acquisition_channel=event for a booth scan. Anything you set here overrides values from the file.
  • Save unmapped columns as extra_data — when on, every column you mapped to skip (or didn’t map at all) is preserved as a JSON dict on each account. Use this when the source has fields LeadHunter doesn’t model but you don’t want to lose them.
  • Enrich accounts after import — when on, every newly-created account is queued for website discovery + scrape (logo, phone, email, address, the per-platform social URLs, year founded, employee-count bucket, business language, and the business name when the row arrived without one) once the rows land. The enrich runs as a separate background job that shows on the Runs page. Off by default — it costs AI calls and time. Tick it for a small trusted list; leave it off for a 50k-row directory unless you actually want every row enriched. You can always enrich later from Accounts → More → Enrich (see After the import: enrichment).
  • Source label — a free-text tag stamped on each imported row’s source field. Defaults to the filename (lower-case, with separators tidied — radio_stations_radiobrowser.xlsx becomes radio stations radiobrowser). Edit it to whatever provenance marker reads best to you and your teammates: "LinkedIn export 2026-Q2", "Trade show 2026", "Stripe customers", etc. Beyond the label, every imported row also carries the file’s MD5 fingerprint invisibly, so you can later answer “show me every account that came from this specific upload” exactly even if multiple imports share the same label.
  • Dry-run — runs every dedupe check and shows you what would happen (new accounts, duplicates skipped, with the reason for each skip) without writing. Recommended for any import larger than a handful of rows.

Files above about 2,000 rows are handed to a background worker so the browser doesn’t sit on the request. You’ll see a live progress bar with rows-processed / total, animating as the import advances:

  • You can close the dialog at any time — the import keeps running on the server, and the accounts show up on Accounts as soon as it finishes.
  • The worker imports rows in chunks of 2,500, each in its own transaction. If a single row in chunk N gets rejected by the database (e.g. a column type that doesn’t fit), only that chunk rolls back — the chunks that already committed stay in place. You can fix the offending rows in the source file and re-import just them, instead of losing 50,000 good rows because of 100 bad ones.
  • The job also shows on the Runs page (under Imports, enrichment and searches) as a row of method CSV Import. Click it for full details (timing, totals, error log).
  • Cancel on the Runs page stops the import: once it confirms, no more rows are written. A chunk that was already being written finishes first (so the confirmation can take as long as one chunk), and the chunks that landed stay — use Undo this import to remove them.

Smaller files (and any dry-run) finish synchronously — same modal, no progress step. Smaller imports also run as a single transaction (no chunking), since one rejected row in a 200-row file is small enough to retry whole.

When the real import finishes, the summary shows New accounts, Updated, Duplicates skipped and Errors. A skipped duplicate is a row that matched an existing account (or an earlier row of the file) on Google Place ID, imported ID or name + city; it was not imported and its values were not copied into the existing account. Rows that only share a phone, a website or a similar name were imported as new accounts — run Find duplicates (in the More menu of the Accounts page) to review and merge them.

For chunked imports, a small footnote lists how the file split — e.g. “Imported in 21 chunks of 2,500 rows. 19 committed, 2 rolled back.” If everything went well, all chunks committed and the import is complete. If some chunks fail (bad rows in only part of the file), the import is partially complete — the good chunks landed, the bad ones didn’t, and the result panel shows which row numbers tripped the database so you can fix them.

If LeadHunter had to change any value to make it fit, an amber box headed “N values adjusted or cleared” lists the count per field with the reason, and an Examples list shows the row number and the original value. The rows still imported. The reasons:

  • shortened to fit — an over-long value was cut to the column limit (Name is capped at 255 characters, Website at 500, Language at 50, etc.). Fix the source file if the lost tail matters.
  • not a number, cleared / out of range, cleared — see numeric columns below.
  • not a valid option, default used — see option columns below.

Numeric columns: a cell that isn’t a number (n/a, abc) or doesn’t fit the field (a vote count of 41086 mapped to rating, which tops out at 9.9) is cleared instead of failing the row (which used to roll back its whole chunk). The import response’s per-row details give the reason: not_a_number or out_of_range.

Option columns (status, acquisition_channel, the employee-count bucket) only accept their known values. Spelling and case don’t matter: Customer, customer and CUSTOMER all land as customer, and Meta Ads or meta-ads as meta_ads. Anything else (status=Active) is dropped so the field keeps its default (status prospect, channel outbound, no bucket) and is counted with the reason invalid_choice. Before, those values landed verbatim and no filter or report recognised them.

A column where most cells were adjusted usually means a wrong column mapping; undo the import and map it again.

Rows that fail validation (a bad date, a value the field won’t accept) are skipped, not silently dropped: an Invalid rows skipped box shows how many, with the first 25 row numbers and what was wrong with each. The rest of the file still imports, and the import is marked partially complete; if no row was valid, the panel says “Import failed — no valid rows”.

If every chunk rolled back (the source file is broken end-to-end), the panel turns red, says “Import failed — nothing saved”, and lists the underlying database errors. Nothing lands; fix the source and retry.

The panel lists at most 25 example rows, but the whole list is kept. Two download buttons appear when there is something to download:

  • Rows not imported (N) — the rows that were skipped as invalid or rejected by the database, with its row number (1 = the first row under the header), the account name and external ID it carried, and what was wrong. Fix those rows in your file and import just them.
  • Duplicates skipped (N) — the rows skipped as duplicates, with the reason (same name and city, same imported ID, …), the value that matched, the confidence and the ID of the account it matched (an account already in the project, or an earlier row of the same file). Use it to check the skips, or to add a phone the file had to the existing account.

Both are CSV files. Each keeps the first 10,000 rows of its kind, so a re-import that skips 55,000 duplicates lists the first 10,000 of them; a note under the buttons says when there were more, and the counts in the summary are always complete. The lists are stored with the import, so they can still be fetched later through the API (GET /api/research/{id}/import-report.csv/?kind=invalid or duplicates); in the app the buttons are on this result panel.

View these N accounts opens the Accounts list filtered to exactly the accounts this import created — not the duplicates it skipped, not the same file imported on another day. A From import · N accounts chip shows the filter (its × shows every account again). From there you can rank them against a product’s fit criteria, enrich them, export them or Add matching accounts to campaign like any other list.

Rank these N accounts, right below it, opens the same filtered list with the Rank dialog already open, so you can give the new accounts a fit score for a product straight away. You see the estimate first; nothing starts by itself.

Both use the same rule as Undo this import below, so “the accounts of this import” always means the same set.

Picked the wrong file by accident? On the result panel, the Undo this import button deletes every account that landed from this file in one click — plus any campaign memberships, conversation messages, and scores that got attached to those accounts in the meantime. The import history record stays so you have an audit trail, but the rows are gone.

Every account an import creates is tagged with that import, so Undo deletes exactly those accounts: never accounts from a different upload, from an earlier or later import of the same file (even one started a minute later), or entered by hand. Importing the same file twice by mistake is harmless: the second import skips every row as a duplicate, and undoing it deletes nothing. That holds for a run that was cancelled or failed too.

Imports made before 8 October 2026 aren’t tagged. For those, Undo finds the accounts by the file’s fingerprint and the time the import ran. If another import of the same file ran at almost the same time and the two can’t be told apart, Undo refuses and deletes nothing; open View these N accounts and delete the rows you don’t want by hand.

The undo is destructive and cannot be reversed. If you imported a 50k-row CRM export, ran outreach campaigns against some of those rows, and then click Undo, those campaign rows and the messages you sent are gone too. Most operators want Undo right after the import lands (“oops, wrong file”); after that point, fixing duplicates one by one with Find duplicates is usually safer than wholesale revert.

Best for entering a new market — “who are all the bike shops in Berlin?” Open Accounts → Find new accounts, click New Research, pick Google Maps API, enter the search terms and the countries / cities to cover, and start it. LeadHunter runs one search per term and place, pages through the Google Maps results, fetches the place details for each, and adds them as accounts. Progress and cost show on the Runs page. The run ends completed when every search worked, partial when some failed (for example a quota error half-way), and failed when none did — see Research for retrying.

Results are matched on their Google Place ID, so re-running the same query a month later doesn’t double-count: a place already in the project is counted as a duplicate and left as it is (its details are not refreshed). Only the Place ID is checked — a business you already have without a Place ID (from a file import, say) is added as a second account, so run Find duplicates afterwards.

Requires Google Maps API to be configured on your environment; ad-hoc lookups by Maps URL or domain don’t depend on it.

LeadHunter can run an enrichment pass on every freshly-imported account that:

  1. Tries to discover the website via Gemini grounding (and a domain-guessing fallback when the model has nothing).
  2. Scrapes the discovered URL and caches the text content on the account.
  3. Extracts structured fields from the page: logo, phone, email, address, the per-platform social profile URLs (LinkedIn / Instagram / X / Facebook / YouTube / TikTok), year_founded, the coarse employee_count_estimate bucket, primary business language, and the business name when the row arrived without one.

Three ways this gets triggered:

  • Single-account lookup (Quick / Deep modes) runs the enrich inline — it’s part of the lookup itself.
  • Bulk import (CSV / XLSX) — tick Enrich accounts after import during step 3. The enrich runs as a separate background job once the rows land. Off by default so you can decide; a 50k-row directory imported without the box ticked stays cheap and fast, and you can enrich a subset later from More → Enrich on the Accounts page.
  • Google Maps discovery rows arrive already-populated from the Maps result (website + phone + address), so the enrich is usually a no-op and is skipped.

On the Accounts page, More → Enrich (in the same menu as Export and Find duplicates) opens a small dialog with three modes:

  • Only accounts not enriched yet — skips every account whose website was already read. Recommended for project-wide enrichment.
  • First 50 (test run) — caps the run at the 50 oldest accounts not enriched yet. A cheap smoke test before kicking off a multi-hour pass on a large project.
  • Accounts the list shows now — uses the list’s current search and filters (status, country, business type, advanced conditions …) and enriches only those rows. Useful when you want to enrich a specific country slice or every “prospect” before a campaign launch.

To enrich a few accounts you picked, tick their rows and click Enrich in the bar that appears: the dialog then offers the selected accounts as a fourth choice.

Whichever mode you pick, the run lands on the Runs page with a real live progress percentage — not the placeholder it used to park at. Large jobs (>200 accounts) are sliced into chunks server-side so the percentage advances steadily instead of jumping from 0% to 100% at the end, and so a redeploy mid-enrich doesn’t lose the work that already landed (it picks up where it left off automatically — see below).

LeadHunter tracks enrichment state at three independent levels, so manual edits and partial fills always survive a re-enrich:

  1. Row level — should we scrape at all? A row counts as already enriched when it has cached website content and the scrape is recent. Stale rows (older than the scrape cache window) re-enter the queue automatically.
  2. Field level — should we overwrite? Every structured field (phone, email, the social URLs, year_founded, employee_count_estimate, …) is only written when the current value is blank. So if you manually corrected the LinkedIn URL on one account, a re-enrich never overwrites it.
  3. Discovery level — did we already try? When website discovery fires (Gemini grounding + domain guessing) and finds nothing, the row is flagged “discovery attempted”. Subsequent runs skip it so we don’t loop on the same dead-ends. A failure on the AI provider’s side (quota exhausted, a timeout) doesn’t count as a try: the row stays eligible and the next run searches again. On an account page, Find & Enrich (for an account with no website) then tells you the provider is unavailable and to try again later, instead of offering to type the URL by hand.

You’ll see freshly-imported accounts grow website content, a language tag, socials, founding year, and a headcount bucket over the following minutes. The activity is tracked under API costs and visible on the Runs page.

Deploy-safety: large enrich jobs survive a redeploy

Section titled “Deploy-safety: large enrich jobs survive a redeploy”

Enrichment runs that take longer than a worker’s lifetime (long imports, big project-wide enriches) used to risk losing in-flight work if the platform redeployed mid-run. The current pipeline:

  • Slices large jobs into chunks that each finish well within a worker’s time limit.
  • Persists the chunk plan so a worker that picks up after a restart knows exactly which slices already landed and which still need to run.
  • Auto-resumes orphaned runs when a fresh worker comes online and once an hour on a steady cadence. A job that was interrupted continues from where it left off; you don’t need to click Retry.

If a run can’t be auto-resumed (a CSV import whose upload-cache expired, for example), it’s flagged failed on the Runs page with a one-line explanation and a Retry button.

Each row is checked against your existing accounts and against the rows above it in the same file (since 2026-10-03), using the detection levels: Google Place ID, imported ID, name + city, phone, website domain, similar name in the same city. What the import does with a match:

  • Google Place ID, imported ID or name + city (95–100%) — the row is skipped. No account is created, and the row’s values are not copied into the existing account: if the file has a phone number the existing account lacks, add it by hand. Skipped rows count under Duplicates skipped. The imported ID is what makes monthly CRM re-exports clean — every row that already exists is found by it, even when name + city don’t agree or are blank.
  • Phone, website domain or similar name (85–94%) — the row is imported as a new account, with no flag. Run Find duplicates on the Accounts page afterwards to review and merge these.

A file that lists the same shop twice with the same name and city therefore imports it once; two rows that only share a phone number both import.

Names are compared word by word, and single letters and numbers count: Radio 3 and Radio 5, or Radio C and Radio X, in the same city are different accounts, not duplicates.

If another import, or a push through the upsert API, creates an account with the same Google Place ID or imported ID while your import is running, the clashing row is skipped as a duplicate and counted with the other skipped rows; the rest of its chunk still imports. Before, the whole chunk rolled back.

For the full rules on every path, the Find duplicates workflow and the field-winner rules of a merge, see Merge duplicates.

  • Importing without dry-run. For anything over ~50 rows, run dry-run first. It costs nothing and tells you exactly which rows will be skipped as duplicates, and why.
  • Skipping the column mapping review. The header match and the AI suggestion are usually right, but scan them — a Notes column that lands on Specialization (or a vote count on Rating) is annoying to clean up later.
  • Forgetting to set acquisition_channel for inbound imports. If you’re loading a Stripe-customer export or a CSV of Adwords form sign-ups, set acquisition_channel as a static value (adwords, cold_inbound, etc.) so the rows land at status='contacted' and count as inbound in Insights → Breakdown instead of looking like outbound prospects.
  • Re-importing a file you’ve already imported. LeadHunter detects this — the mapping step shows a yellow warning with the prior import’s date, who ran it, and how many rows are still around. Every row that carries a Google Place ID, an imported ID, or both a name and a city is then skipped, so re-importing an unchanged file is a safe no-op; rows with neither (no ID, no city) can be imported twice. The trap is when the file changed (new export from the same CRM) but you didn’t map a stable id: without imported_id, only an identical name + city (or Place ID) stops a row, so a changed name or a missing City column creates duplicates. Map the source’s stable id to imported_id for any dataset you’ll re-import regularly.
  • Merge duplicates — the duplicate rules for every path, and how to find and merge the duplicates that got through.
  • Track inbound accounts — attribute imports from Ads / forms / referrals correctly.
  • Build a saved filter — once accounts are in, slice them into campaign-ready lists and save them as views.