SkynetLabs
Back to journal
AutomationSeptember 21, 202612 min read

n8n Google Maps Lead Harvest: Trade and City In, a Deduped Lead Table Out

By , Founder, SkynetLabs

This n8n workflow wrote 100 Denver roofing businesses to Postgres in 31.9 seconds on 2026-09-18, deduped on place_id via the Apify Maps actor.

On this page

This n8n workflow turns a trade and a city into a deduped lead table in Postgres. On 2026-09-18 it wrote 100 businesses in 31.9 seconds for the query "roofing contractors Denver CO" (n8n execution 26333, run_id 20260918-1945). Google Maps data arrives through the Apify actor compass/crawler-google-places. The JSON is MIT-licensed.

If you sell to local operators, the list is the whole job. Every agency owner has at some point paid somebody on a marketplace for a spreadsheet of roofers, received 400 rows with three columns of nonsense, and spent an evening deduping it by hand. The list is not hard to get. A list you can trust the second time you run it, that does not silently repeat last week's rows, is harder.

Episode 03 of the n8n Workflows series is that second version. You type a trade, a city and a state into one Set node and press Execute. The workflow starts an Apify actor for that one search string, waits for it with a bounded poll loop, pulls the dataset, cleans each business, drops the obvious non-operators, dedupes on the Google place_id, and upserts everything into its own Postgres table stamped with a run id. The last node emits one summary item so the number you quote is the number the table actually holds.

If iframes are blocked where you are reading this, the video is at youtu.be/FwBva2bm_d8.

Where the data comes from. This workflow does not scrape Google itself. Every Maps call goes through the Apify actor compass/crawler-google-places, and the workflow only starts the run, polls it and reads the resulting dataset. Business listings only, no person data.

Specifications

Trade and city in. A Postgres table of local operators out, deduped on place_id, stamped with a run id, with a summary item reporting rows seen, rows written and elapsed seconds. 18 nodes, of which 15 do work and 3 are sticky notes.
Item Value
Workflow name 03 Maps Lead Harvest (Apify -> Postgres)
Nodes 18 (15 working nodes plus 3 sticky notes)
Trigger Manual Execute. There is no form; you edit the Inputs Set node.
Inputs trade, city, state
Data source Apify actor compass/crawler-google-places
Storage Postgres table ep03_leads, keyed on place_id, indexed on run_id
Dedupe key place_id, in code during normalisation and again via ON CONFLICT in SQL
Caps 100 places per run, 20 polls at 30 seconds, 900-second workflow timeout
Output One summary item: run_id, query, rows_seen, rows_written, rows_deduped, seconds
Public JSON includes All 18 nodes, the actor request body, the normalisation code and the upsert statement, plus schema.sql. Credentials stripped to REPLACE_ME.
Licence MIT

How does the n8n Google Maps workflow build the lead table?

In five moves: start the actor, poll until it finishes, fetch the dataset, normalise and dedupe in a Code node, then upsert into Postgres one statement per row. A final Code node reports what happened. Nothing reads environment variables, because n8n 2.x Code nodes run in task runners with no $env, so configuration travels through the workflow as data on a Set node.

Config derives everything from your three inputs: the query is the trade plus the city, the run id is a timestamp down to the minute, the start time is stamped for the elapsed-seconds calculation, and the ceiling is 100 places. Start Apify Actor then POSTs the run asynchronously and keeps the run id and the default dataset id. Asynchronous is not a style preference: the synchronous run endpoints time out past 300 seconds, and a real Maps query for 100 places can take longer than that.

The poll loop is Wait, Poll, Check, repeated. Check Finished reads the status, counts the attempt from the loop index, and sets a proceed flag when either the status is terminal or the attempt count reaches 20. That second condition is the circuit breaker. A stuck actor run can cost you ten minutes of waiting, and then the loop stops regardless of status rather than spinning until the workflow timeout. Anything other than a succeeded status ends at a NoOp node and writes nothing at all.

Normalize + Dedupe is where the table is actually made. It flattens whatever shape the dataset endpoint returned, drops rows with no place id, drops obvious non-operators by category (supplier, wholesale, distributor, store, charity, non-profit), reduces the phone to digits, reduces the website to a bare host, and keeps a Set of place ids so a business appearing on two result pages is emitted once. It attaches rows_seen and rows_written to every item it emits, and it throws if there are zero usable rows.

Node Type What it does
Click to run Manual trigger Starts the run.
Inputs Set trade, city, state. The only node you edit.
Config Set query, run_id, started_at, maxResults 100. Keeps the input fields for later nodes.
Start Apify Actor (async) HTTP POSTs a run of compass/crawler-google-places with the search string and location query.
Prep Poll Set Keeps the run id and the default dataset id.
Wait 30s Wait The poll interval.
Poll Run Status HTTP GETs the actor run and returns its status.
Check Finished Code Status, attempt number, succeeded flag, and the proceed flag that breaks the loop at 20.
Run Finished? / Run Succeeded? IF Loop back, or fall through to Run failed and write nothing.
Fetch Dataset Items HTTP GETs the dataset items as clean JSON.
Normalize + Dedupe Code Filters, cleans phone and website, dedupes on place_id, throws on zero rows.
UPSERT ep03_leads Postgres One statement per row, ON CONFLICT (place_id) DO UPDATE, returning the place id.
Run Summary Code run_id, query, rows_seen, rows_written, rows_deduped, seconds.

What is real in the public JSON and what is a placeholder?

All 18 nodes, the actor request body, the normalisation code, the upsert statement and schema.sql are exactly what ran. Two credentials ship as REPLACE_ME: a Query Auth credential named Apify Token (REPLACE_ME) that appends the token to all three HTTP calls, and a Postgres credential named Postgres (REPLACE_ME).

Two more things worth knowing before you import it:

  • The table is its own. The workflow writes to ep03_leads and nothing else. Run schema.sql first; it creates the table keyed on place_id with an index on run_id and a seen_at stamp on every upsert.
  • Postgres is swappable. n8n's built-in Data Tables can hold this table if you would rather not run an external database. Replace the upsert node with a Data Table upsert on place_id and drop schema.sql. That variant is not built in the repo.

What did the measured run produce?

The run wrote 100 businesses to Postgres in 31.9 seconds on 2026-09-18, under n8n execution 26333 and run_id 20260918-1945, for the query "roofing contractors Denver CO". The summary item for that run reported 100 rows seen, 100 rows written and 0 deduped.

Zero duplicates is worth reading correctly. It does not mean the dedupe is decorative; it means that on this particular query the actor's page walk happened not to repeat a business. rows_seen minus rows_written is exactly the number of duplicates the page walk produced, and on a broader query or a denser city that number stops being zero. The 31.9 seconds is also dominated by how long Apify took, not by n8n, so it will move with the query and the actor's own queue.

Which build gotchas cost a failed run?

The n8n Code sandbox has no URL constructor

This is the one that cost the most time, because it failed quietly.

The website column is meant to hold a bare host: example.com, not https://www.example.com/contact?utm_source=maps. The obvious implementation is new URL(raw).hostname wrapped in a try/catch, because some of those strings are junk and you do not want one bad row killing the run.

The n8n Code node does not run in a browser and does not expose browser globals. There is no URL and no URLSearchParams. So every single call threw, the catch swallowed every single throw, and the fallback ran for every row. There is no error in the execution log, because the code did exactly what it was told. The output just quietly holds the wrong shape of value in every row, which you will not notice until you look at the table, and might not notice then.

The fix is string operations: strip the scheme, split on the first slash, question mark and hash, drop anything before an at sign, drop a trailing port, drop a leading www.. Less elegant, works everywhere. The broader lesson is that a try/catch which always catches is indistinguishable from a try/catch that never runs, so if a fallback path seems to be producing every row, test the happy path directly instead of trusting that it exists.

One related detail: has_website is set from the raw field before cleaning, so a junk website string still counts as having one. For this kind of list, has_website = false is usually the most interesting column on the table.

The actor run is asynchronous and the poll has to be bounded

The synchronous run endpoints time out past 300 seconds, and a real Maps query for 100 places can exceed that, so the workflow starts the run and polls it. The bound matters as much as the poll: at 20 attempts the loop stops regardless of status, so a stuck actor can never spin the workflow until its own 900-second timeout. A non-succeeded status ends at a NoOp node and writes nothing.

Cap Where Value
Places per run Config.maxResults 100
Poll ceiling Check Finished 20 polls at 30 seconds, about 10 minutes, then it gives up
Execution timeout Workflow settings 900 seconds
HTTP timeouts Per node Start 60s, poll 30s, fetch 60s

Dedupe happens in code first, and in SQL second

The actor walks the Maps results page by page and the same business can appear on two pages. The Code node keeps a Set of place ids and emits each one once. The upsert's ON CONFLICT (place_id) is the second line of defence, which is what protects you when you run the same query again next week.

Phone is digits only, and short numbers become NULL

Everything non-numeric is stripped, and anything under 10 digits is not a dialable number and is stored as NULL. Format on the way out, not on the way in. The column is text, not a number, because leading zeros and country codes matter and integers destroy both.

Zero rows throws on purpose

If the actor returns no usable places, the Code node throws rather than writing an empty run. The execution goes red, and the summary never gets to claim zero rows written as a success. A green run that wrote nothing is the failure mode that wastes a week.

How do I run the Maps lead harvest workflow myself?

Import the free workflow JSON (MIT) from github.com/waseemnasir2k26/n8n-workflows/tree/main/workflows/03-maps-lead-harvest, run schema.sql against a Postgres database, create the two credentials, edit the three strings on the Inputs node, and press Execute.

What you have to supply:

  1. An Apify API token, as a Query Auth credential named token. All three HTTP nodes append it to every call.
  2. A Postgres database with schema.sql applied. The workflow writes to ep03_leads and nothing else.

On running costs, in general terms: the Apify actor run is the metered part and it is billed to your own Apify account by what the run consumes, while n8n and Postgres are infrastructure you host. One search string at 100 places is a small run. Check the actual cost on the actor run page in your own account before you raise maxResults or add more search strings, because both of those multiply the metered side and neither of them touches the rest of the workflow.

Frequently asked questions

Can n8n scrape Google Maps?

Not directly, and this workflow does not try. It calls the Apify actor compass/crawler-google-places, which does the Maps work, and n8n starts the run, polls it, and reads the resulting dataset. That keeps the Maps interaction in one auditable place and leaves n8n doing orchestration, cleaning and storage.

How do I dedupe Google Maps results in n8n?

Twice. In the Code node, keep a JavaScript Set of place_id values and emit each business once, which handles the actor walking the same business onto two result pages. Then key the Postgres table on place_id and use ON CONFLICT (place_id) DO UPDATE, which handles re-running the same query later.

Is the n8n Code node able to use the URL constructor?

No. The Code node does not run in a browser and does not expose browser globals, so URL and URLSearchParams are undefined. Calls to new URL() throw, and a try/catch around them silently falls through to the fallback on every row. Parse hosts with string operations instead.

How long does a 100-business Maps harvest take?

One measured run wrote 100 businesses in 31.9 seconds on 2026-09-18, under n8n execution 26333 and run_id 20260918-1945, for the query "roofing contractors Denver CO". Most of that elapsed time is the Apify actor rather than n8n, so it moves with the query, the city density and the actor's own queue.

What do I need to run this n8n workflow?

Two credentials and one database: an Apify API token as a Query Auth credential named token, and a Postgres credential with schema.sql already applied. Both ship as REPLACE_ME in the public JSON. You then edit three strings, trade, city and state, on the Inputs node.

The rest of the series

One workflow per video, the JSON given away under MIT each time, built by Waseem Nasir at SkynetLabs. The other three published episodes:

WhatsApp +92 300 1001957 · Waseem Nasir, SkynetLabs

Hire SkynetLabs, our Top Rated agency on Fiverr: https://www.fiverr.com/agencies/skynetjoellc

Want this kind of result for your business?

Send a brief. Yes/no in 8 hours. No funnel.

Send a brief