This workflow corresponds to n8n.io template #16872 — we link there as the canonical source.
This workflow follows the Form Trigger → Google Sheets recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"nodes": [
{
"id": "46ba4cf8-de50-4cc3-b1dc-fb4148cc88ef",
"name": "Sticky Note",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1680,
-128
],
"parameters": {
"width": 480,
"height": 752,
"content": "## Collect local business leads from Google Maps to Google Sheets via ScraperAPI\n\n### How it works\n\n1. Initializes the workflow by creating a leads spreadsheet.\n2. Listens for lead search requests to start the search process.\n3. Configures the search query using provided parameters.\n4. Fetches and processes data from Google Maps via ScraperAPI.\n5. Deduces new leads and appends them to the spreadsheet.\n\n### Setup steps\n\n- [ ] Set up Google Sheets API credentials for creating and accessing spreadsheets.\n- [ ] Configure ScraperAPI with an API key.\n- [ ] Ensure form trigger is set up to capture lead search requests.\n\n### Customization\n\nAdjust the query parameters in the 'Configure' node to refine search criteria (e.g., change country code, rating, or max results)."
},
"typeVersion": 1
},
{
"id": "ef74b78c-380e-4419-945e-3ae9b487f730",
"name": "Sticky Note1",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1120,
-128
],
"parameters": {
"color": 7,
"width": 432,
"height": 304,
"content": "## Initialize leads spreadsheet\n\nManually creates an initial Google Sheets document for storing leads."
},
"typeVersion": 1
},
{
"id": "d6eadd70-a8d9-4959-ba32-03af96e8d132",
"name": "Sticky Note2",
"type": "n8n-nodes-base.stickyNote",
"position": [
-1120,
352
],
"parameters": {
"color": 7,
"width": 928,
"height": 272,
"content": "## Lead search trigger and configuration\n\nTriggers the lead search process and configures search criteria."
},
"typeVersion": 1
},
{
"id": "eedcb8fb-84c0-471c-ba26-62748e318e8e",
"name": "Sticky Note3",
"type": "n8n-nodes-base.stickyNote",
"position": [
-128,
352
],
"parameters": {
"color": 7,
"width": 976,
"height": 272,
"content": "## Fetch and process map data\n\nHandles search query execution, data fetching from Google Maps, and URL extraction."
},
"typeVersion": 1
},
{
"id": "ccf85a39-ff80-4c2f-bbfd-f2e2e42d095f",
"name": "Sticky Note4",
"type": "n8n-nodes-base.stickyNote",
"position": [
896,
320
],
"parameters": {
"color": 7,
"width": 432,
"height": 304,
"content": "## Deduplicate and append leads\n\nRemoves duplicate leads and appends new ones to the Google Sheets document."
},
"typeVersion": 1
},
{
"id": "setup-trigger",
"name": "Create Leads Sheet (run once)",
"type": "n8n-nodes-base.manualTrigger",
"position": [
-1072,
0
],
"parameters": {},
"typeVersion": 1
},
{
"id": "create-spreadsheet",
"name": "Create Leads Spreadsheet",
"type": "n8n-nodes-base.googleSheets",
"position": [
-832,
0
],
"parameters": {
"title": "Google Maps Leads",
"options": {},
"resource": "spreadsheet",
"sheetsUi": {
"sheetValues": [
{
"title": "Leads"
}
]
},
"operation": "create"
},
"typeVersion": 4.5
},
{
"id": "form-trigger",
"name": "On Lead Search Request",
"type": "n8n-nodes-base.formTrigger",
"position": [
-1072,
460
],
"parameters": {
"options": {},
"formTitle": "Find local business leads",
"formFields": {
"values": [
{
"fieldLabel": "Niche",
"placeholder": "plumbers",
"requiredField": true
},
{
"fieldLabel": "Location",
"placeholder": "Austin, TX",
"requiredField": true
},
{
"fieldType": "number",
"fieldLabel": "Max results",
"placeholder": "20",
"requiredField": false
}
]
},
"formDescription": "Enter a niche and a location to pull matching businesses from Google Maps."
},
"typeVersion": 2.2
},
{
"id": "configure",
"name": "Configure",
"type": "n8n-nodes-base.set",
"position": [
-832,
460
],
"parameters": {
"options": {},
"assignments": {
"assignments": [
{
"id": "query",
"name": "query",
"type": "string",
"value": "={{ $json.Niche }} in {{ $json.Location }}"
},
{
"id": "country-code",
"name": "countryCode",
"type": "string",
"value": "us"
},
{
"id": "min-rating",
"name": "minRating",
"type": "number",
"value": 0
},
{
"id": "max-results",
"name": "maxResults",
"type": "number",
"value": "={{ Number($json['Max results']) || 20 }}"
}
]
}
},
"typeVersion": 3.4
},
{
"id": "read-existing-leads",
"name": "Read Existing Leads",
"type": "n8n-nodes-base.googleSheets",
"position": [
-580,
460
],
"parameters": {
"options": {},
"operation": "read",
"sheetName": {
"__rl": true,
"mode": "list",
"value": ""
},
"documentId": {
"__rl": true,
"mode": "list",
"value": ""
}
},
"typeVersion": 4.5,
"alwaysOutputData": true
},
{
"id": "build-search",
"name": "Build Search Query",
"type": "n8n-nodes-base.code",
"position": [
-340,
460
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// Re-emit a single search item, decoupled from the rows returned by the\n// existing-leads read, so the ScraperAPI nodes run exactly once no matter\n// how many leads are already stored in the sheet.\n\nconst cfg = $('Configure').first().json;\n\nreturn [{\n json: {\n query: cfg.query,\n countryCode: cfg.countryCode,\n },\n pairedItem: { item: 0 },\n}];\n",
"language": "javaScript"
},
"typeVersion": 2
},
{
"id": "fetch-maps-metadata",
"name": "Fetch Maps Metadata via ScraperAPI",
"type": "n8n-nodes-scraperapi-official.scraperApi",
"maxTries": 2,
"position": [
-80,
460
],
"parameters": {
"resource": "sde",
"sdeQuery": "={{ $json.query }}",
"operation": "googleMapsSearch",
"sdePlatform": "google",
"sdeGoogleMapsSearchOptions": {
"countryCode": "={{ $json.countryCode }}"
}
},
"retryOnFail": true,
"typeVersion": 1,
"continueOnFail": true,
"waitBetweenTries": 2000
},
{
"id": "extract-maps-url",
"name": "Extract Results URL",
"type": "n8n-nodes-base.code",
"position": [
160,
460
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// The Google Maps structured endpoint returns a metadata document, not the\n// businesses: the actual results live behind `next_page_url`, a ready-made\n// ScraperAPI request wrapping the Google Maps results page. Pull out the\n// inner Google URL so the next node can fetch and autoparse it.\n\nconst getBody = (item) => {\n const resp = item && item.json && item.json.response;\n if (!resp || resp.body === undefined) return {};\n if (typeof resp.body === 'string') {\n try { return JSON.parse(resp.body); } catch (e) { return {}; }\n }\n return resp.body;\n};\n\nconst meta = getBody($input.first());\nconst nextPage = meta.next_page_url || meta.nextPageURL || '';\n\n// Pull the `url` query param out by hand \u2014 the n8n Code sandbox does not\n// reliably expose the URL constructor. The inner Google URL is percent-\n// encoded, so it never contains a literal `&` to break the match.\nconst match = /[?&]url=([^&]+)/.exec(nextPage);\nlet targetUrl = '';\nif (match) {\n try { targetUrl = decodeURIComponent(match[1]); } catch (e) { targetUrl = match[1]; }\n}\n\nreturn [{ json: { targetUrl }, pairedItem: { item: 0 } }];\n",
"language": "javaScript"
},
"typeVersion": 2
},
{
"id": "fetch-maps-results",
"name": "Fetch Maps Results via ScraperAPI",
"type": "n8n-nodes-scraperapi-official.scraperApi",
"maxTries": 2,
"position": [
400,
460
],
"parameters": {
"apiUrl": "={{ $json.targetUrl }}",
"resource": "api",
"operation": "apiRequest",
"apiOptionalParameters": {
"apiAutoparse": true,
"apiCountryCode": "={{ $('Configure').first().json.countryCode }}"
}
},
"retryOnFail": true,
"typeVersion": 1,
"continueOnFail": true,
"waitBetweenTries": 2000
},
{
"id": "extract-businesses",
"name": "Extract Businesses",
"type": "n8n-nodes-base.code",
"position": [
700,
460
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// Parse the autoparsed Google Maps results and flatten each business into a\n// clean lead row. Applies the configured minimum-rating filter (on stars).\n// Result fields: name, address (array), address_line, stars, ratings, type\n// (array), url (website), phone_number, latitude, longitude, district.\n\nconst cfg = $('Configure').first().json;\nconst minRating = Number(cfg.minRating) || 0;\n\nconst getBody = (item) => {\n const resp = item && item.json && item.json.response;\n if (!resp || resp.body === undefined) return {};\n if (typeof resp.body === 'string') {\n try { return JSON.parse(resp.body); } catch (e) { return {}; }\n }\n return resp.body;\n};\n\nconst numOrNull = (v) => {\n if (v === null || v === undefined || v === '') return null;\n const s = typeof v === 'string' ? v.replace(/[^0-9.]/g, '') : v;\n const n = parseFloat(s);\n return Number.isFinite(n) ? n : null;\n};\n\nconst joinList = (v) => (Array.isArray(v) ? v.filter(Boolean).join(', ') : (v || ''));\n\nconst scrapedAt = new Date().toISOString();\nconst out = [];\n\n$input.all().forEach((item, i) => {\n const body = getBody(item);\n const results = body.results || body.local_results || [];\n if (!Array.isArray(results)) return;\n\n for (const b of results) {\n const rating = numOrNull(b.stars ?? b.rating);\n if (minRating > 0 && (rating === null || rating < minRating)) continue;\n\n const name = b.name || b.title || '';\n const address = joinList(b.address) || b.address_line || '';\n const mapQuery = `${name} ${address}`.trim();\n\n out.push({\n json: {\n name,\n category: joinList(b.type),\n address,\n phone: b.phone_number || b.phone || '',\n website: b.url || b.website || '',\n rating,\n reviews: numOrNull(b.ratings ?? b.reviews),\n district: b.district || '',\n latitude: b.latitude ?? null,\n longitude: b.longitude ?? null,\n google_maps_url: mapQuery ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(mapQuery)}` : '',\n search_query: cfg.query,\n scraped_at: scrapedAt,\n },\n pairedItem: { item: i },\n });\n }\n});\n\nreturn out;\n",
"language": "javaScript"
},
"typeVersion": 2
},
{
"id": "dedupe-new-leads",
"name": "Dedupe New Leads",
"type": "n8n-nodes-base.code",
"position": [
940,
460
],
"parameters": {
"mode": "runOnceForAllItems",
"jsCode": "// Drop businesses already in the sheet (matched by name + address) and any\n// duplicates within this batch, then cap to the configured maximum so a broad\n// query can't flood the sheet.\n\nconst maxResults = Number($('Configure').first().json.maxResults) || 20;\n\nconst keyOf = (r) =>\n `${String(r.name || '').toLowerCase().trim()}|${String(r.address || '').toLowerCase().trim()}`;\n\nconst seen = new Set(\n $('Read Existing Leads').all()\n .map((r) => keyOf(r.json || {}))\n .filter((k) => k && k !== '|')\n);\n\nconst out = [];\nfor (const item of $input.all()) {\n const lead = item.json;\n const key = keyOf(lead);\n if (!key || key === '|' || seen.has(key)) continue;\n seen.add(key);\n out.push({ json: lead, pairedItem: item.pairedItem });\n if (out.length >= maxResults) break;\n}\n\nreturn out;\n",
"language": "javaScript"
},
"typeVersion": 2
},
{
"id": "append-leads",
"name": "Append Leads to Sheet",
"type": "n8n-nodes-base.googleSheets",
"position": [
1180,
460
],
"parameters": {
"columns": {
"value": {
"name": "={{ $json.name }}",
"phone": "={{ $json.phone }}",
"rating": "={{ $json.rating }}",
"address": "={{ $json.address }}",
"reviews": "={{ $json.reviews }}",
"website": "={{ $json.website }}",
"category": "={{ $json.category }}",
"district": "={{ $json.district }}",
"latitude": "={{ $json.latitude }}",
"longitude": "={{ $json.longitude }}",
"scraped_at": "={{ $json.scraped_at }}",
"search_query": "={{ $json.search_query }}",
"google_maps_url": "={{ $json.google_maps_url }}"
},
"mappingMode": "defineBelow"
},
"options": {},
"operation": "append",
"sheetName": {
"__rl": true,
"mode": "list",
"value": ""
},
"documentId": {
"__rl": true,
"mode": "list",
"value": ""
}
},
"typeVersion": 4.5
}
],
"settings": {
"executionOrder": "v1"
},
"connections": {
"Configure": {
"main": [
[
{
"node": "Read Existing Leads",
"type": "main",
"index": 0
}
]
]
},
"Dedupe New Leads": {
"main": [
[
{
"node": "Append Leads to Sheet",
"type": "main",
"index": 0
}
]
]
},
"Build Search Query": {
"main": [
[
{
"node": "Fetch Maps Metadata via ScraperAPI",
"type": "main",
"index": 0
}
]
]
},
"Extract Businesses": {
"main": [
[
{
"node": "Dedupe New Leads",
"type": "main",
"index": 0
}
]
]
},
"Extract Results URL": {
"main": [
[
{
"node": "Fetch Maps Results via ScraperAPI",
"type": "main",
"index": 0
}
]
]
},
"Read Existing Leads": {
"main": [
[
{
"node": "Build Search Query",
"type": "main",
"index": 0
}
]
]
},
"On Lead Search Request": {
"main": [
[
{
"node": "Configure",
"type": "main",
"index": 0
}
]
]
},
"Create Leads Sheet (run once)": {
"main": [
[
{
"node": "Create Leads Spreadsheet",
"type": "main",
"index": 0
}
]
]
},
"Fetch Maps Results via ScraperAPI": {
"main": [
[
{
"node": "Extract Businesses",
"type": "main",
"index": 0
}
]
]
},
"Fetch Maps Metadata via ScraperAPI": {
"main": [
[
{
"node": "Extract Results URL",
"type": "main",
"index": 0
}
]
]
}
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
This workflow collects local business leads from Google Maps using ScraperAPI and stores them in Google Sheets, letting you submit a niche and location via an n8n form and automatically deduplicate and append new businesses to your leads spreadsheet. Creates a new Google Sheets…
Source: https://n8n.io/workflows/16872/ — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This repository contains an SLA-based lead routing workflow built in n8n, designed to ensure fast lead response, fair sales distribution, and controlled escalation without relying on a full CRM system
This workflow collects business leads from Google Maps by geocoding a user-provided city, searching across a small coordinate grid with Places Nearby Search pagination, and writing enriched business d
How it works A form trigger accepts an Industry + Location query (e.g. Accountants London). Text Search Page 1 calls Google Places Text Search to return results and a nextpagetoken. Conditional checks
Agencies, sales teams, and service businesses who want to instantly qualify inbound leads with an AI-powered phone call — no manual follow-up needed.
This n8n template automates lead generation by scraping Google Maps using the Olostep API. It extracts business names, locations, websites, phone numbers, and decision-maker names (CEO, Founder, etc.)