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.
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
| 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_leadsand nothing else. Runschema.sqlfirst; it creates the table keyed onplace_idwith an index onrun_idand aseen_atstamp 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_idand dropschema.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:
-
An Apify API token, as a Query Auth credential named
token. All three HTTP nodes append it to every call. -
A Postgres database with
schema.sqlapplied. The workflow writes toep03_leadsand 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:
- Episode 01, n8n Shorts Factory: one topic string in, a captioned 9:16 Short out, with the publish step deliberately switched off.
- Episode 02, n8n speed-to-lead: a form fill gets an AI reply in about two seconds, a second model qualifies the follow-up and proposes a visit window.
- Episode 04, n8n freight quote parser: a quote email in, eight validated fields in Postgres out, with the reply left as a draft.
WhatsApp +92 300 1001957 · Waseem Nasir, SkynetLabs
Hire SkynetLabs, our Top Rated agency on Fiverr: https://www.fiverr.com/agencies/skynetjoellc