Activepieces + Scrapeless: A No-Code Local Lead Flow
Web Data Collection Specialist
TL;DR:
- Activepieces reaches live search data with its built-in HTTP piece, so a lead flow needs no custom piece, no published package, and no service to host.
- The Google Search actor returns about 20 local businesses per request — captures landed on 20, 21 and 22 — so a local flow usually needs a single call plus a deduplication key rather than a pagination loop.
- The parsed response sits under
body, which makes{{step_1.body.local_results.places}}the working reference; dropbodyand the loop runs zero times inside a flow that still reports success. - The
phonefield holds a phone number in only about half of the records — the rest carry opening hours or a service label — so a Code piece must validate it, not just trim it. - Store the API key as a project-level value rather than typing it into the header field, because flows get exported and shared.
What This Flow Gives You
A working Activepieces flow that turns a category and a city into rows of local businesses — name, category, rating, review count, and phone — ready for a CRM, a sheet, or a database.
Activepieces orchestrates apps well and does not fetch pages. Search results come from Deep SerpApi through its scraper.google.search actor, which returns the parsed local pack as JSON. The flow below was built on a self-hosted Activepieces 0.82.0 instance with piece-http 0.11.18.
Prerequisites
- An Activepieces instance, cloud or self-hosted
- A Scrapeless API key — create a free account
- A destination piece for the rows: Google Sheets, Airtable, Postgres, or your CRM
Put the key in a project-level value or a connection, not in the step's header field. A flow definition carries its literal field values, and flows are exported, duplicated, and shared between projects.
Configure the HTTP Step
Add HTTP → Send HTTP request and fill in five fields:
| Field | Value |
|---|---|
| Method | POST |
| URL | https://api.scrapeless.com/api/v1/scraper/request |
| Headers | x-api-token → your key |
| Body type | JSON |
| Body | the object below |
json
{
"actor": "scraper.google.search",
"input": {
"q": "plumbers in Austin, TX",
"tbm": "lcl"
}
}
tbm set to lcl is what returns businesses instead of web pages. The query needs local intent: "plumbers in Austin, TX" returns a local pack, while a bare "plumbing" often does not.
Before wiring the rest of the flow, confirm the request outside the builder. The same call from a shell tells you whether an empty result is the query's fault or the flow's:
bash
curl -sS -X POST https://api.scrapeless.com/api/v1/scraper/request \
-H 'Content-Type: application/json' \
-H "x-api-token: $SCRAPELESS_API_KEY" \
-d '{"actor":"scraper.google.search","input":{"q":"plumbers in Austin, TX","tbm":"lcl"}}' \
| python3 -c 'import json, sys
data = json.load(sys.stdin)
places = (data.get("local_results") or {}).get("places") or []
print(len(places), "places")
if places:
print("raw phone:", repr(places[0]["phone"]), "| raw type:", repr(places[0]["type"]))
else:
print("no local pack in this response; top-level keys were", sorted(data))'
That prints the place count and the first record's raw phone and type — the two fields that arrive padded. Reading the keys on the else branch rather than indexing straight into local_results is the same habit the flow needs: check that the shape you expect is present before you address into it.
What Comes Back
A successful run returns about 20 places under local_results.places, each carrying title, type, rating, reviews, phone, and address. Captures landed on 20, 21 and 22 places for the same query, so treat the page size as approximate rather than fixed: add "start": 20 to the input object for the next page, and deduplicate on name plus phone instead of assuming exact page boundaries.
With tbm set to lcl the envelope is local_results, metadata, pagination, and search_information — no organic_results and no related_searches. A rejected call returns an envelope carrying code and message instead of any results, which is why the flow should branch on the presence of local_results rather than on the HTTP status alone.
The reference path is the part worth getting right. In Activepieces the parsed JSON lives under body:
| Reference | Result |
|---|---|
{{step_1.body.local_results.places}} |
the array of places |
{{step_1.body.organic_results}} |
web results, when tbm is omitted |
{{step_1.organic_results}} |
nothing — no error, no warning |
That last row is the expensive one. A reference missing the body segment resolves to nothing, the Loop on Items step iterates zero times, and the run still finishes as succeeded. An empty destination table looks identical whether the query returned nothing or the reference was wrong, so check the path first.
Add Loop on Items over {{step_1.body.local_results.places}} so each business is handled as its own item rather than one blob written to a single cell.
Building this on the free plan is enough to reach a full local-pack response — start with a Scrapeless account and keep the key in a project value.
Normalize Before You Store
Two behaviors decide whether your rows are usable, and the second one is the reason a lead flow needs code at all.
Strings arrive padded. phone, type, and hours carry a leading space — " Plumber", " (512) 690-4935". That is not cosmetic: a padded phone number used as a deduplication key creates a second record for the same business on the next run, and a category filter on "Plumber" matches nothing. The ITU-T numbering plan recommendation is the reason to normalize a phone number to a canonical form before it becomes an identifier.
Several fields are present but carry nothing usable. In the same capture, place_id, thumbnail, and lsig were empty in all 20 records. gps_coordinates is the trap: it is present as {"latitude": 0, "longitude": 0}, so a truthiness check passes and a mapping step plots every business at the same point on the equator. Take location from address and treat the coordinate pair as absent unless both values are non-zero.
The phone field is not always a phone number. In a 20-place capture for "plumbers in Austin, TX", only 11 records carried a phone-shaped value. The other nine held opening-hours text such as " Closes 6 PM " or a service label such as "Online estimates". Map that field straight into a CRM column and nearly half the rows arrive unusable, with no error anywhere in the flow. Some of those hours strings also contain a narrow no-break space (U+202F) rather than a normal space, so a naive split on " " behaves unexpectedly even after trimming.
Validate the field instead of trusting its name, and keep the discarded text rather than dropping it:
Add a Code piece between the request and the destination. Activepieces wraps the body as export const code = async (inputs) => { … }; the logic inside is plain JavaScript:
javascript
// `phone` sometimes carries opening hours or a service label instead of a number,
// so the value is validated before it becomes a contact field.
const PHONE = /\(?\d{3}\)?[ -]?\d{3}-?\d{4}/;
const code = async (inputs) => {
const clean = (value) => (typeof value === 'string' ? value.trim() : value);
const places = inputs.response?.local_results?.places ?? [];
return places.map((place) => {
const contact = clean(place.phone) ?? '';
const isPhone = PHONE.test(contact);
return {
name: clean(place.title),
category: clean(place.type),
rating: place.rating ?? null,
reviews: place.reviews ?? 0,
phone: isPhone ? contact : null,
phone_field_note: isPhone ? null : contact,
address_snippet: clean(place.address),
};
});
};
const sample = {
response: {
local_results: {
places: [
{
title: 'Radiant Plumbing, Air Conditioning, & Electrical',
type: ' Plumber',
rating: 4.8,
reviews: 18000,
phone: ' (512) 690-4935',
address: '25+ years in business \u00b7 Austin, TX',
},
{
title: 'Beyond Wow Plumbing & Drains',
type: ' Plumber',
rating: 4.9,
phone: ' Closes 6\u202fPM ',
address: 'Austin, TX',
},
],
},
},
};
code(sample).then((rows) => console.log(JSON.stringify(rows, null, 2)));
Pass {{step_1.body}} into the piece's response input. Two details matter here. The ?? 0 default exists because a business with no reviews has no reviews key at all, and a numeric destination column rejects undefined while accepting 0. And phone_field_note keeps whatever occupied the field when it was not a number, so an operator can see that a row has opening hours rather than a missing phone.
Rating and review count are the two fields worth keeping numeric. Everything else is text, and if the flow ends in a spreadsheet export rather than a database, the comma-separated values format specification is what decides how a business name containing a comma survives the round trip.
Handling Business Contact Data Responsibly
This flow collects business contact details, so a few obligations come with it. Collect only from public search results and only the fields the workflow needs. Keep a lawful basis for storing contact data and honour opt-out requests, since a business phone number can still identify a sole trader as an individual — the General Data Protection Regulation applies to personal data even in a commercial context, and equivalent rules exist in other jurisdictions. Respect each destination platform's terms for imported contacts, set a retention period rather than keeping rows indefinitely, and follow the marketing-consent rules of the country you are contacting. None of this is legal advice; check your own obligations before you run outreach.
Conclusion
Three pieces make the whole flow: HTTP to call the actor, Code to trim and default the fields, Loop on Items to write one row per business. The failure mode to watch sits in the reference path: drop body and a successful run writes an empty table.
From here, swap the query for a list of cities and the same flow becomes a territory build. The Make integration walkthrough covers the same request from a different no-code builder, and the Dify monitoring build shows the agent-driven version of the same actor.
Ready to build it? Review the Deep SerpApi documentation for the full parameter set, compare plans and included volume, and start on the free plan.
FAQ
Q: Do I need a custom Activepieces piece to use Scrapeless?
No. The built-in HTTP piece covers every actor, because the API takes a single POST with an actor field and an input object. A custom piece only helps if you want a branded step with typed fields for a team that should not see the raw request, and that is a packaging decision rather than a capability one.
Q: Why does my Loop on Items step iterate zero times when the HTTP step succeeded?
The parsed response is nested under body, so {{step_1.local_results.places}} resolves to nothing while {{step_1.body.local_results.places}} resolves to the array. A missing reference raises no error, so the flow reports success with an empty loop. Check the reference path before investigating the query.
Q: How many results does one request return, and how do I get more?
A local-pack request returns about 20 places; repeat captures of one query returned 20, 21 and 22. Add "start": 20 to the input object for the next page, "start": 40 for the one after, and so on. Because the page size is not exactly fixed, deduplicate on name plus phone rather than trusting offsets to align, and treat a short final page as the end of the set.
Q: Why are place_id and gps_coordinates unusable on local results?
place_id, thumbnail, and lsig come back empty on every local-pack record, so a flow that requires a place_id discards all 20 results. gps_coordinates behaves differently and more dangerously: it is populated with {"latitude": 0, "longitude": 0}, which survives an emptiness check while pointing every business at the same coordinate. Use address for location and only trust the coordinate pair when both numbers are non-zero.
Q: Where should the API key live in an Activepieces flow?
In a project-level value or a connection, referenced from the header field. Flow definitions carry literal field values and get exported and duplicated between projects, so a key typed directly into the step travels with every copy.
Q: Can this flow run on a schedule instead of a webhook?
Yes. Swap the trigger for Schedule and the rest of the flow is unchanged, which is the usual shape for a territory refresh. Keep the run frequency matched to how often local rankings actually move; daily is ample for most categories, and a slower cadence keeps volume predictable.
Q: Does the same flow work for web results instead of businesses?
Yes. Drop tbm from the input object and results arrive under {{step_1.body.organic_results}} instead, each with title, link, and snippet. The Code piece needs its path updated to match, and the trimming is unnecessary on web results.
At Scrapeless, we only access publicly available data while strictly complying with applicable laws, regulations, and website privacy policies. The content in this blog is for demonstration purposes only and does not involve any illegal or infringing activities. We make no guarantees and disclaim all liability for the use of information from this blog or third-party links. Before engaging in any scraping activities, consult your legal advisor and review the target website's terms of service or obtain the necessary permissions.



