This guide builds an n8n workflow that turns “dentists in Irvine” into a Google Sheet of leads with verified emails. It uses only standard n8n nodes: HTTP Request, Wait, If, Split Out, Filter and Google Sheets. You can run it on n8n Cloud or self-hosted.
The flow:
- Start a LeadMerlin search job
- Wait, then poll the job until it’s
completed - Fetch all results, following pagination
- Split the businesses into items and keep the ones with a verified, non-role email
- Append or update rows in Google Sheets
Before you start
- A LeadMerlin API key (starts with
lm_; the free plan includes 100 lead credits) - An n8n instance
- A Google Sheet with a header row. We’ll use:
name, owner, email, email_status, phone, phone_type, website, city, rating, reviews, cms, gbp_url
Create the credential once
In n8n, create a Header Auth credential:
- Name:
x-api-key - Value: your
lm_…key
Every HTTP Request node below uses this credential. The key is never pasted into a node body.
Node 1: Trigger
Use a Manual Trigger while building, then switch to a Schedule Trigger if you want a weekly refresh. Add a Set node after it holding your inputs:
{ "keyword": "dentist", "location_codes": [1013883], "strictness": "moderate" }
1013883 is Irvine, CA. To find codes for other cities, call GET https://api.leadmerlin.com/api/v2/locations?q=your city once (it’s free) and copy the location_code. One job accepts up to five codes.
Node 2: Start the search
HTTP Request node:
- Method:
POST - URL:
https://api.leadmerlin.com/api/v2/searches - Authentication: Generic → Header Auth → your credential
- Send Body: JSON
{
"keyword": "{{ $json.keyword }}",
"location_codes": {{ JSON.stringify($json.location_codes) }},
"strictness": "{{ $json.strictness }}"
}
The API answers 202 with a job_id. From this point you are charged one credit for each business that has a website. Listings without a website and off-niche listings are free.
Nodes 3–5: The polling loop
Enrichment crawls each business’s website, verifies emails and looks up phone line types, so a job takes a while. Typically that’s a minute or two, longer for big cities. Build a simple loop:
- Wait node: 20 seconds.
- HTTP Request node:
GET https://api.leadmerlin.com/api/v2/jobs/{{ $('Start search').item.json.job_id }}with the same credential. - If node:
{{ $json.status }}is equal tocompleted.- true → continue to results
- false → connect back to the Wait node
Add a second condition so a failed job doesn’t loop forever: route status = failed to a Stop and Error node.
The status response is worth logging, because it explains the result count:
{
"status": "completed",
"total_unique": 2,
"enriched_count": 2,
"skipped_count": 0,
"off_niche_count": 0,
"category_facets": { "Plumber": 2 },
"progress": 100
}
(That sample comes from the free demo endpoint at /api/v2/demo/jobs/x. Point the nodes at /api/v2/demo/... while building and nothing is charged.)
Node 6: Fetch all results with pagination
HTTP Request node:
- Method:
GET - URL:
https://api.leadmerlin.com/api/v2/jobs/{{ $('Start search').item.json.job_id }}/results - Query parameter:
limit=200
Open Options → Pagination:
- Pagination mode: Update a Parameter in Each Request
- Parameter: Query, name
cursor, value{{ $response.body.next_cursor }} - Pagination complete when: Other →
{{ $response.body.has_more === false }}
Each page has the shape { businesses: [...], next_cursor, has_more }.
Node 7: Split Out
Split Out node on the field businesses. Each business becomes its own item.
Node 8: Keep only the leads you can email
Filter node with two conditions (AND):
{{ $json.contact.all_emails.find(e => e.email === $json.email)?.status }}is equal toverified{{ $json.contact.all_emails.find(e => e.email === $json.email)?.is_role_based }}is false
email is the best address LeadMerlin picked for the business. contact.all_emails[] lists every address found, each with:
status:verified,catch-all,invalidorunverifiedis_role_based: info@, office@ and similaris_free_provider: Gmail, Yahoo and so on
Loosen the filter if you also want catch-all addresses, which accept everything and can’t be confirmed mailbox by mailbox.
Node 9: Shape the row
A Set node maps the nested JSON to flat columns:
| Column | Expression |
|---|---|
| name | {{ $json.name }} |
| owner | {{ $json.owner }} |
{{ $json.email }} | |
| email_status | verified |
| phone | {{ $json.phone }} |
| phone_type | {{ $json.phone_type }} |
| website | {{ $json.website }} |
| city | {{ $json.contact.city }} |
| rating | {{ $json.reputation?.rating }} |
| reviews | {{ $json.reputation?.reviews }} |
| cms | {{ $json.web.tech?.cms }} |
| gbp_url | {{ $json.gbp_url }} |
Node 10: Google Sheets
Google Sheets node → Append or Update Row:
- Column to match on:
website(orgbp_urlfor businesses without one) - Mapping: Map Automatically, since the Set node already uses your header names
Matching on website means re-running the workflow next month updates existing rows instead of duplicating them.
Variations worth building
- Qualify by tech stack. Add a Filter on
web.tech.chatbeing empty orweb.tech.cmsbeingwix, which is useful if you sell chat widgets or websites. See how to qualify leads with tech stack signals. - Enrich a list you already have. Swap Node 2 for
POST /v2/enrichmentswith{ "leads": [{ "domain": "example.com" }] }(up to 100 per request). It uses the same job polling and results flow. - Send to a CRM. Replace the Sheets node with HubSpot, Pipedrive or an HTTP call to your outreach tool.
- Multi-city runs. Pass up to five location codes in one job instead of looping cities.
Costs and limits to keep in mind
- One lead credit per business with a website. Website-less and off-niche listings are free, and failed enrichments are refunded.
- If a search matches more businesses than your balance covers, it enriches the top-ranked ones your balance pays for and reports the rest as
over_budget_count. Passmax_leadsto cap a search yourself. - Polling, locations and suggest calls don’t use credits, but a 20-second wait between polls is plenty.
Before you email anyone on the sheet, read our CAN-SPAM checklist for cold email to local businesses.