Google Maps Chain Location Grouper
Pricing
from $10.50 / 1,000 location assessments
Google Maps Chain Location Grouper
Group Google Maps locations by observed hostname and compatible brand. Preserve each address and show the evidence for each location assessment, without claiming verified corporate ownership.
Google Maps Chain Location Grouper — link likely chain branches in an existing Maps export by hostname and brand
Feed it a Google Maps places batch you already scraped and get back one location assessment per unique place — an observed hostname+brand correlation flagged for your account-planning verification queue, never a confirmed chain or a legal-ownership claim.

This Actor does not scrape Google Maps. It reads places already extracted by a Maps scraper — most naturally Google Maps Scraper or Google Maps Extractor — via Dataset, run, or inline items, and links likely chain branches by an exact, non-shared corporate hostname plus a compatible normalized brand name. It is a B2B account-planning research tool for teams working from a multi-city batch they already collected — it is not a corporate-ownership verifier, a franchise-registry lookup, or a legal-entity resolver.
What you get
For each distinct place found across your source batch, you get one location assessment record containing:
entityId,placeId,cid— the place's stable identity, bounded and type-validated.hostname,normalizedBrand— the corporate hostname extracted fromwebsite(ornullif missing/blank/invalid/excluded), and the brand name normalized for matching (legal suffixes like Inc/LLC/Ltd stripped, punctuation collapsed).status,relationship,groupKey,groupSize,groupMembers—standalone,shared_brand_hostname_group, orexcluded_platform, the matching relationship label, a stable group key, the true group size, and up to 50 lightweight cross-reference entries for other members of the same group.action—add_to_verification_queuefor a group match,no_action_standalonefor a unique hostname+brand, orno_action_excluded_platformfor a known shared-platform host.confidence— a fixed, conservative heuristic (0.65 for an observed group match; 0.4–1 for a standalone, depending on why grouping wasn't possible) — never a calibrated probability of actual corporate ownership.evidence— up to 50 entries, each citing the literal source field, the exact rule that fired, and the source row it came from.duplicateSourceRowCount,partial— how many source rows shared this exactplaceId, and a composite disclosure flag for any conflict, truncation, or unresolved signal.- Honest diagnostics for everything else — a row with no
placeId/cid, an oversized identity, or a place with no usable hostname AND no usable, non-generic brand, are all free rows.
What this Actor is explicitly not: no claim of verified corporate ownership, no franchise-registry confirmation, no legal-entity resolution. A shared_brand_hostname_group match is an OBSERVED, batch-local correlation only — the shared hostname might simply be an uncatalogued generic hosting domain outside this Actor's finite exclusion list. Distinct branches are always preserved as separate locations with their own address, even when linked into the same group.
Who uses it
- B2B account planners and franchise-development teams consolidating a new multi-city Maps export into research-ready account groups, instead of manually cross-referencing addresses.
- Sales and territory-mapping teams who need a defensible reason two listings were grouped, rather than an opaque "these might be related" guess.
- Data-quality teams auditing an upstream Maps scrape, using resolution states as a signal for how clean the underlying data is.
- Competitive-intelligence researchers mapping a competitor's multi-location footprint from a batch of Maps listings they already collected.
Not a scraper: point it at an existing places Dataset from an upstream Actor, most naturally Google Maps Scraper (users30d=37173, successfulRuns30d=3,751,440) or Google Maps Extractor (users30d=4575, successfulRuns30d=327,497). This Actor never starts either one, and never fetches a page from the open web.
How to run
- Console (fastest for a one-off run). Open the Actor, go to Input, paste rows into Source records (inline) or fill in Source Dataset, leave defaults, hit Start.
- From an existing Maps Dataset. Put its Dataset ID in
datasetId— read with your own run's permissions, no separate authorization step. - Chained via Integrations. Add this Actor as an "Actor/task run" integration on your upstream Maps-scraper Actor; map
{{resource.defaultDatasetId}}into this Actor'sdatasetIdfield explicitly (see Integration recipes) to request aREADgrant — a bare Dataset ID or webhook payload alone only requests access, it never guarantees it. - Via the API. Call
POST /v2/acts/6OruMc0vPsndzCW0s/runs(orrun-sync-get-dataset-items) withdatasetId(oritems) and, for a spending ceiling, the run optionmaxTotalChargeUsd. - Tune the matching strictness.
brandCompatibilityMode(prefixdefault orexact) controls how strictly normalized brand names must match;additionalExcludedHostnamesadds your own shared-platform exclusions on top of the built-in list. - Read the results. One row per unique place lands in the run's default Dataset, with a matching
OUTPUT/SUMMARYrecord describing delivery, billing, and coverage.
Every run is independent processing — see Operating guide for what that means for re-running against the same batch.
Pricing
Pay-per-event, one event type: result-found ("Location assessment" in the platform's pricing UI — the primary pricing noun used throughout this listing, matching this product's own noun field) — one delivered location assessment (one unique place). There is no start fee, and every free diagnostic (a missing identity, an unresolved relationship, a budget stop, an empty batch) requests no charge event at all.
| Tier | Price per location assessment | Discount |
|---|---|---|
| Free (default) | $0.015 | — |
| Bronze | $0.0135 | 10% |
| Silver | $0.012 | 20% |
| Gold | $0.0105 | 30% |
| Platinum | $0.0105 | 30% |
| Diamond | $0.0105 | 30% |
These are Apify's platform-wide account pricing tiers — not a volume schedule this Actor invents — assigned automatically by your account's usage; you don't select one. One location assessment is one unique place across the whole batch, never billed per duplicate source row.
A note on the evidence in this README: every output example below comes from the product owner's own verification runs against this Actor's accepted build — cost to the owner, not sales or customer revenue.
Set the platform's own Max total charge (USD) run option to cap spend. If testing the zero-paid path via a raw API call, do not rely on literal maxTotalChargeUsd: 0 — the platform's own API-level cap-normalization behavior means a positive value below every tier's price only reliably guarantees zero paid results when the effective cap the run actually enforces is preserved at that value — see Evidence and boundaries for the real recorded run demonstrating this.
Input contract
| Field | Type | Default | Notes |
|---|---|---|---|
items | array (JSON editor), max 1000 entries | prefilled real example | Inline source records. Ignored if datasetId, runId, or a resolvable payload.resource is also given. |
datasetId | string (Dataset picker, READ) | — | Dataset ID from a Maps-scraper run. Read with your own run's permissions. Overrides inline items. |
runId | string, max 64 chars | — | An Actor run ID to read instead of a Dataset ID; its default Dataset resolves under your own permissions — no upstream Actor started. |
payload | object (JSON editor) | — | Optional ACTOR.RUN.SUCCEEDED-shaped payload carrying resource.defaultDatasetId/resource.id. The datasetId resource picker is the recommended way to request access; a bare payload reference alone does not grant it. |
maxInputRows | integer | 100 | Hard cap on source records examined before grouping (1–1000). Still bounded to 4 MiB total / 64 KiB per row. |
maxResults | integer | 10 | Max useful results this run can deliver (1–1000), independent of the platform's Max total charge option. |
brandCompatibilityMode | string (prefix/exact) | prefix | prefix allows "Metro Cafe" and "Metro Cafe Express" to link (every whole word of the shorter normalized brand matches the longer one, in order); exact requires identical strings. Hostname match is always required either way — brand alone never links locations. |
additionalExcludedHostnames | array of bare hostnames | [] | Extra shared-platform hostnames to exclude, on top of the built-in list. Each entry must be a bare hostname — no scheme/path/port/userinfo/whitespace; an invalid entry rejects the whole batch as invalid_domain_options, never silently ignored. |
Source precedence, exactly: an explicit datasetId or payload.resource.defaultDatasetId (they must agree if both are present) beats a runId or payload.resource.id, which beats inline items. A dataset/run source is never merged with inline rows. A malformed or conflicting reference is rejected as invalid input, never silently downgraded to the inline example. Private-Dataset access is conditional: the resource picker or an authorized Integrations mapping can request a READ grant, but neither one guarantees the platform actually honors it — a bare Dataset ID string or webhook reference alone never grants access to data you aren't already permitted to read.
Accepted row fields (any subset present is used; nothing is strictly required except a place identity):
| Field | Role |
|---|---|
placeId / cid | Primary entity identity. placeId (string, ≤500 chars) is preferred; cid (string or finite number) is used when placeId is absent. Over the bound, the row is rejected outright (oversized_identity), never truncated. |
website | Resolved to dataset_missing (key absent), observed_absent (explicit blank), invalid_url (unsupported scheme, malformed URL, or userinfo present), excluded_platform (a known shared booking/social/website-builder host or subdomain), or usable (a real hostname extracted, ≤253 chars). |
title | Normalized into a matching brand key: lowercased, punctuation stripped, legal suffixes (inc/llc/ltd/co/corp/company/group) removed. Over 500 characters is rejected outright as unresolved — never truncated, since truncating a matching key risks merging two genuinely different brands sharing a long common prefix. A brand normalizing to only generic words (company/store/shop/business/group/market/services/shops/stores, articles ignored) is never usable clustering evidence either. |
phone, address, city, countryCode, categoryName | Pass-through display fields, each bounded and disclosed if truncated or malformed; none is a chain-linking signal on its own — a shared phone number alone never collapses two locations into one group. |
scrapedAt | Picks the canonical occurrence when duplicate rows share the same identity — most recent valid timestamp wins, else the last occurrence deterministically. |
found, failed, status, error, errors | An explicit hard-failure signal is always a free source_reported_failure diagnostic, even with found: true/partial: true also present. A non-empty error/errors alone is a harmless warning ONLY when the same row also explicitly carries found: true and partial: true; otherwise it still produces the free diagnostic. |
Hard read bounds, always enforced regardless of maxInputRows: at most 1000 source rows, at most 4 MiB of total source JSON per run, at most 64 KiB per individual row (an oversized row is dropped and disclosed). This Actor reads only the fixed https://api.apify.com origin with your run's own token — it never fetches an arbitrary URL and never starts an upstream Actor.
Output examples
Every example below is a real record from an actual accepted run of this Actor's build 0.1.2 (Actor ID 6OruMc0vPsndzCW0s, build f5ZhSXhcljvptI1pa), reproduced exactly — nothing is hand-edited. Source dates come from scrapedAt (2026-09-22T23:07:38–42Z); the runs were processed 2026-09-23 — a saved snapshot, not a live re-check. This real 3-place London-museum batch happens to have three distinct, non-shared hostnames — a genuine, honest standalone outcome for each, not a fabricated grouped example.
1. First useful assessment — a full, real dataset row
This run used the Actor's own real inline prefill: 3 real London museum listings. Both diagrams on this page illustrate this exact run — the same 3 real places, all delivered as paid results.
Actor input (first of three real prefill rows; abbreviated to the fields this Actor reads):
{"title": "Natural History Museum","categoryName": "Natural history museum","address": "Cromwell Rd, South Kensington, London SW7 5BD, United Kingdom","city": "London","countryCode": "GB","website": "https://www.nhm.ac.uk/","phone": "+44 20 7942 5000","placeId": "ChIJPy8Y5kIFdkgRxGSXw4Xjt3s","cid": "8914844151054820548","scrapedAt": "2026-09-22T23:07:38.397Z","_exampleProvenance": {"sourceActorId": "nwua9Gu5YrADL7ZDj","sourceRunId": "5VeVFvxu0MQhPBrWY","sourceDatasetId": "WJkSy7pXIRxQlwPvn","curatedPublicSnapshot": true,"redaction": "Allowlisted public business/product fields only; review excerpts and social captions truncated; no reviewer identities."}}
First Dataset output row (of three delivered from this run):
{"entityId": "ChIJPy8Y5kIFdkgRxGSXw4Xjt3s","found": true,"status": "standalone","action": "no_action_standalone","confidence": 1,"sourceRowCount": 1,"placeId": "ChIJPy8Y5kIFdkgRxGSXw4Xjt3s","cid": "8914844151054820548","title": "Natural History Museum","titleTruncated": false,"website": "https://www.nhm.ac.uk/","websiteTruncated": false,"phone": "+44 20 7942 5000","phoneTruncated": false,"address": "Cromwell Rd, South Kensington, London SW7 5BD, United Kingdom","addressTruncated": false,"city": "London","cityTruncated": false,"countryCode": "GB","countryCodeTruncated": false,"categoryName": "Natural history museum","categoryNameTruncated": false,"hostname": "nhm.ac.uk","normalizedBrand": "natural history museum","groupKey": "standalone:ChIJPy8Y5kIFdkgRxGSXw4Xjt3s","groupSize": 1,"relationship": "standalone","groupMembers": [],"groupMembersTruncated": false,"duplicateSourceRowCount": 1,"observedAt": "2026-09-22T23:07:38.397Z","sourceObservationWindow": { "earliest": "2026-09-22T23:07:38.397Z", "latest": "2026-09-22T23:07:38.397Z", "validCount": 1, "totalRowCount": 1 },"evidence": [{ "field": "website", "value": "nhm.ac.uk", "rule": "no_peer_in_batch: hostname \"nhm.ac.uk\" resolved but no other distinct place id in the provided dataset shares it; this reflects the provided batch only, not an independent company-ownership verification", "sourceRowIndex": 0 }],"businessPartial": false,"sourceRowIndexes": [0],"partial": false,"sourceCoverage": { "complete": true, "reason": null, "droppedOversized": 0 },"product": "google-maps-chain-location-grouper","schemaVersion": "1.0","sourceDatasetId": null,"sourceRunId": null,"suppliedSourceRunId": null,"sourceRunIdVerified": null,"processedAt": "2026-09-23T05:39:27.922Z"}
observedAt/sourceObservationWindow reflect the sole contributing occurrence's real, whitespace-trimmed scrapedAt — earliest/latest are identical here because only one row contributed.
hostname: "nhm.ac.uk" resolved from the website, and normalizedBrand: "natural history museum" from the title. Because no other place in this 3-row batch shares that exact hostname, the result is standalone — a real, paid conclusion, not an unresolved diagnostic and not a false grouping claim.
2. Derived partial-flag control — same real values, an explicit incompleteness flag injected
This is not a demonstration of removed columns or actual upstream data incompleteness. It is the identical real discovery batch as example 1, with each source row's own partial field deliberately set to true (and found: true added), to exercise this Actor's disclosed-incompleteness path under a controlled, known input. Every underlying value is identical and real; only the incompleteness flag is a deliberate test control, not an observed condition from any actual scrape.
Actor input (identical to example 1, except "found": true, "partial": true added):
{"title": "Natural History Museum","categoryName": "Natural history museum","address": "Cromwell Rd, South Kensington, London SW7 5BD, United Kingdom","city": "London","countryCode": "GB","website": "https://www.nhm.ac.uk/","phone": "+44 20 7942 5000","placeId": "ChIJPy8Y5kIFdkgRxGSXw4Xjt3s","cid": "8914844151054820548","scrapedAt": "2026-09-22T23:07:38.397Z","_exampleProvenance": {"sourceActorId": "nwua9Gu5YrADL7ZDj","sourceRunId": "5VeVFvxu0MQhPBrWY","sourceDatasetId": "WJkSy7pXIRxQlwPvn","curatedPublicSnapshot": true,"redaction": "Allowlisted public business/product fields only; review excerpts and social captions truncated; no reviewer identities."},"found": true,"partial": true}
Resulting row (businessPartial: true, partial: true — otherwise identical to example 1's row):
{"entityId": "ChIJPy8Y5kIFdkgRxGSXw4Xjt3s","found": true,"status": "standalone","action": "no_action_standalone","confidence": 1,"sourceRowCount": 1,"placeId": "ChIJPy8Y5kIFdkgRxGSXw4Xjt3s","cid": "8914844151054820548","title": "Natural History Museum","titleTruncated": false,"website": "https://www.nhm.ac.uk/","websiteTruncated": false,"phone": "+44 20 7942 5000","phoneTruncated": false,"address": "Cromwell Rd, South Kensington, London SW7 5BD, United Kingdom","addressTruncated": false,"city": "London","cityTruncated": false,"countryCode": "GB","countryCodeTruncated": false,"categoryName": "Natural history museum","categoryNameTruncated": false,"hostname": "nhm.ac.uk","normalizedBrand": "natural history museum","groupKey": "standalone:ChIJPy8Y5kIFdkgRxGSXw4Xjt3s","groupSize": 1,"relationship": "standalone","groupMembers": [],"groupMembersTruncated": false,"duplicateSourceRowCount": 1,"observedAt": "2026-09-22T23:07:38.397Z","sourceObservationWindow": { "earliest": "2026-09-22T23:07:38.397Z", "latest": "2026-09-22T23:07:38.397Z", "validCount": 1, "totalRowCount": 1 },"evidence": [{ "field": "website", "value": "nhm.ac.uk", "rule": "no_peer_in_batch: hostname \"nhm.ac.uk\" resolved but no other distinct place id in the provided dataset shares it; this reflects the provided batch only, not an independent company-ownership verification", "sourceRowIndex": 0 }],"businessPartial": true,"sourceRowIndexes": [0],"partial": true,"sourceCoverage": { "complete": true, "reason": null, "droppedOversized": 0 },"product": "google-maps-chain-location-grouper","schemaVersion": "1.0","sourceDatasetId": null,"sourceRunId": null,"suppliedSourceRunId": null,"sourceRunIdVerified": null,"processedAt": "2026-09-23T05:39:45.900Z"}
Notice the assessment itself is unchanged from example 1; only businessPartial/partial flip to true because the source row disclosed its own incompleteness. This "partial-columns" fixture is a historical folder-name label only: the actual test retains every real value and injects found:true/partial:true as a controlled flag, never removing or blanking a real column.
3. Below-price run — free budget_stopped diagnostic, no partial charge
[{"product": "google-maps-chain-location-grouper","schemaVersion": "1.0","status": "budget_stopped","reason": "Run charge limit cannot cover any result; no source was read.","found": false,"processedAt": "2026-09-23T05:39:57.821Z"}]
This run used the platform run option maxTotalChargeUsd: 0.005 — below every tier's per-assessment price (even Gold/Platinum/Diamond's $0.0105). The Actor never reads the source batch, delivers zero paid assessments, and writes this one free diagnostic. See Evidence and boundaries for the API-level zero-cap caveat.
4. Empty batch — free no_results diagnostic
Actor input:
{ "items": [], "maxInputRows": 100, "maxResults": 10 }
Dataset output:
[{"product": "google-maps-chain-location-grouper","schemaVersion": "1.0","status": "no_results","found": false,"reason": "no_usable_rows_in_batch","sourceCoverage": { "complete": true, "reason": null, "droppedOversized": 0 },"processedAt": "2026-09-23T05:40:05.619Z"}]
An empty or entirely-unusable batch is a free diagnostic, never a silent zero-row success and never a charge.
Field dictionary
Per-assessment result fields:
| Field | Type | Meaning |
|---|---|---|
entityId | string | placeId, or cid:<cid> when placeId is absent — stable key for this place. |
found | boolean | true on every billed assessment; false on every free diagnostic. |
status | string | standalone, shared_brand_hostname_group, or excluded_platform on an assessment; a diagnostic reason (invalid_row, source_reported_failure, missing_identity, oversized_identity, insufficient_source_data, unresolved_relationship, invalid_domain_options, budget_stopped, no_results, max_results_reached) on a free row. |
relationship | string | standalone, observed_brand_host_match, or excluded_platform. observed_brand_host_match is an OBSERVED batch-local correlation only (fixed 0.65 confidence) — never a confirmed chain/ownership claim. |
action | string | no_action_standalone, add_to_verification_queue, or no_action_excluded_platform. |
placeId / cid | string/number, null | Bounded, type-validated identity fields; a malformed non-primary identity is nulled rather than echoed verbatim. |
hostname | string, null | Corporate hostname extracted from website (lowercased, www. stripped), or null if missing/blank/invalid/excluded. |
normalizedBrand | string, null | Brand key derived from title, or null if unresolved/oversized. |
groupKey | string | standalone:<entityId>; hostbrand:<hostname>|<brand> for a linked group; excluded_platform:<entityId>. |
groupSize | number | True count of distinct place ids in this group (1 for standalone/excluded). |
groupMembers | array | Up to 50 other locations in the group, each { placeId, title, address } only — bounded far tighter (80/120 chars) than the primary row's own fields. |
groupMembersTruncated | boolean | true when the group has more than 50 other members; groupSize still reflects the true total. |
title/titleTruncated, website/websiteTruncated, phone/phoneTruncated, address/addressTruncated, city/cityTruncated, countryCode/countryCodeTruncated, categoryName/categoryNameTruncated | string/null, boolean | Pass-through fields bounded per Input contract's length limits; a wrong-typed value is nulled instead and NOT reported as truncated. |
confidence | number | Fixed heuristic: 0.65 for a group match; 1/0.85/0.6/0.5/0.4 for standalone, keyed by why grouping wasn't possible — never a calibrated probability. |
evidence | array | Up to 50 entries, each { field, value, rule, sourceRowIndex }. |
sourceRowCount / sourceRowIndexes | number / array | True count and (capped at 50) list of contributing rows, including failed duplicates. |
duplicateSourceRowCount | number | How many source rows shared this exact placeId. |
businessPartial / partial | boolean | businessPartial is this module's own signal; partial also folds in run-level source incompleteness — check partial if you only check one. |
sourceCoverage | object | Run-level: { complete, reason, droppedOversized }. |
sourceDatasetId, sourceRunId, suppliedSourceRunId, sourceRunIdVerified | string/null, boolean/null | Where the batch came from; sourceRunId set only when independently verified. |
processedAt | string (ISO 8601) | When processed — not when scraped (see scrapedAt in your own rows). |
observedAt | string (ISO 8601), null | The latest strictly calendar-valid scrapedAt across every accepted occurrence contributing to this location (not only the canonical row), computed before any display/evidence cap. The exact captured text, whitespace-trimmed only — never re-serialized, never re-formatted, never a processing-time/"now" fallback. null when no contributing occurrence carried a valid scrapedAt. |
sourceObservationWindow | object | { earliest, latest, validCount, totalRowCount } — earliest/latest are the raw scrapedAt strings as originally reported by the earliest/latest-valid occurrence, whitespace-trimmed only (never re-serialized/re-formatted); validCount is how many contributing occurrences had a strictly valid scrapedAt; totalRowCount is how many accepted occurrences contributed to this location at all — validCount is always ≤ totalRowCount, since an occurrence with an invalid, missing, or date-only scrapedAt still counts toward totalRowCount but never toward validCount. |
reason / error | string | Present on free diagnostic rows only. |
Complete runtime OUTPUT/SUMMARY field table (the Key-value store record under both the OUTPUT and SUMMARY keys — identical payload, two conventional key names; no metadata field exists in this runtime's real output):
| Field | Meaning |
|---|---|
schemaVersion | This product's output schema version string ("1.0"). |
product | "google-maps-chain-location-grouper". |
outcome | delivered, budget_stopped, max_results_reached, no_results, diagnostic, or failed. |
errorCode | Short internal code on a non-nominal outcome, or null. |
fatalFailure | true only when outcome === "failed". |
deliveryUncertain | A Dataset-write or charge acknowledgment is uncertain, including FREE_WRITE_FAILED; raw rows can exceed confirmed counters. |
replayBlocked | true on either of two distinct refusal causes: the run's own result-found event counter didn't match its expected record (EXISTING_RESULT), or this run's claim key was already set from a prior attempt and automatic resurrection is unsupported (EXISTING_CLAIM). |
requested | Source rows actually read this run. |
usefulResults | Results found after oversized-row filtering, before budget/maxResults cap. |
delivered | Paid, confirmed assessments actually written. |
paid | Confirmed result-found charge events, cross-checked against delivered. |
free | Free diagnostic rows actually written — see Evidence and boundaries for how this can exceed the 200-row cap. |
failed | Useful results dropped for exceeding the 64 KiB per-row limit (rare). |
sourcePartial / sourcePartialReason | Whether the source READ was incomplete, and why. |
sourceDroppedOversized | Source rows dropped for exceeding 64 KiB before reaching assessment logic. |
budgetStopped | true if the charge limit was reached before all useful assessments were delivered. |
resultsCapped | true if maxResults was reached before all useful assessments were delivered. |
businessPartialResults | Partial transform results counted before size, budget and result-cap filtering; not a delivered-result count. |
oversizedDiagnosticsDropped | Diagnostic rows dropped for exceeding 64 KiB (rare). |
diagnosticsTruncated | true if more diagnostics existed than the runtime's write cap could hold — see Evidence and boundaries. |
partialOverall | true if any of the above fired — check this one field if you only check one. |
budgetMode | unlimited, finite, or local_no_charging. |
maxTotalChargeUsd | The effective run charge cap enforced, or null when unlimited/local. |
replaySafety | Fixed disclosure string: re-running is new processing, no cross-run dedup. |
businessSummary | { totalSourceRows, resultsCount, diagnosticsCount, statusBreakdown, chainGroupsFound, observedAt } — diagnosticsCount counts only this product's own business diagnostics, never run-level notices layered on top; chainGroupsFound is the true count of distinct hostbrand: group keys formed this run. |
processedAt | ISO 8601 timestamp of this OUTPUT/SUMMARY write. |
Evidence and boundaries
Hard failure always wins, even against a contradictory found:true, partial:true claim. A row with found: false, failed: true, or a status matching a known failure alias (failed/error/timed-out/timedout/timeout/aborted, trim+lowercase-normalized) is always a free source_reported_failure diagnostic — a source cannot simultaneously claim "this row failed" and "this row is a usable partial result." A non-empty error/errors field with no hard-failure signal present is retained as a harmless warning ONLY when the same row explicitly carries both found: true and partial: true; in every other case it still produces the free source_reported_failure diagnostic.
A same-brand + same-hostname match is an observed relationship only, never a corporate-ownership claim. relationship: observed_brand_host_match reflects that this batch's rows share a non-excluded hostname under a compatible normalized brand — nothing more. The shared host might be a generic hosting or site-builder domain not yet added to this Actor's finite exclusion list; confidence: 0.65 is a fixed conservative heuristic, not a calibrated probability, and the action is add_to_verification_queue, never an automatic "confirmed chain" label.
Shared platforms and phone numbers alone never link locations. A hostname matching the built-in list (Facebook, Instagram, Wix, Squarespace, Yelp, and 25+ others, root-or-subdomain match) or your own additionalExcludedHostnames produces status: excluded_platform — an explicit, useful conclusion, distinct from an unresolved relationship. A shared phone number, address, or category is never itself grouping evidence — only hostname + compatible brand.
A brand made up entirely of generic words is never clustering evidence. A normalized brand that reduces to only company/store/shop/business/group/market/services/shops/stores-class tokens (articles ignored) cannot support a grouping or even a standalone "no peer" conclusion — it produces a free unresolved_relationship diagnostic, since a bare "Store" tells you nothing distinguishing about the business.
An oversized normalized brand is rejected outright, never truncated. A brand name over 500 characters becomes an unresolved relationship rather than a truncated matching key — truncating a matching key risks silently merging two genuinely different brands that share a long common prefix. The same applies to an oversized placeId/cid (oversized_identity) and an oversized extracted hostname (invalid_url, never truncated to fit).
Prefix compatibility is word-boundary only, never a character-level substring match. "metro cafe" is compatible with "metro cafe express" (every whole word of the shorter name matches, in order) — but "star" is never compatible with "starfish". Clustering also never transitively bridges through an intermediate-only-linked node: brand A compatible with generic B, and B compatible with unrelated C, never merges A and C unless they are themselves directly compatible.
Duplicate identities are resolved deterministically, and a failed duplicate is disclosed, never scored. When multiple rows share the same placeId/cid, the row with the most recent valid scrapedAt is canonical (else the last occurrence). A duplicate that itself reported a hard failure is excluded from evidence, but disclosed via sourceRowCount and partial: true on the surviving result. Canonical selection is a separate mechanism from observedAt/sourceObservationWindow: canonical selection decides which one occurrence's display fields (title, address, etc.) are used; the observation window spans scrapedAt across every accepted occurrence sharing the identity, whether or not that occurrence is the canonical one.
Bounded, disclosed caps, applied only to display, never to the underlying determination: evidence capped at 50 entries; sourceRowIndexes capped at 50; groupMembers capped at 50 (groupSize is the true total); pass-through fields bounded per Input contract; groupMembers title/address bounded far tighter (80/120 chars) since a member is a cross-reference, not a full record duplicate.
Business diagnostics are capped at 200 written rows — but total free rows for a run can legitimately exceed that, and businessSummary.diagnosticsCount counts business-only. The initial diagnostics array the shared runtime writes from consists of this product's own report.diagnostics PLUS the runtime's own partial_source/source_rows_dropped notices, combined then sliced to 200 written rows; diagnosticsTruncated covers that COMBINED list, while businessSummary.diagnosticsCount counts only this product's own business diagnostics, never the runtime notices layered on top. Separately, later notices — result_dropped_oversized, budget_stopped, or max_results_reached — are written on top of that 200-row cap and are not counted against it, raising the total free-row count above 200. Verified against a real 1,000-row batch (999 synthetic source_reported_failure rows plus a real 3-place batch tail, maxResults: 1): exactly 200 business diagnostics were written, one further max_results_reached notice was added, for 201 total free rows (freeRows: 201) — never a blanket "200 free cap." businessSummary.diagnosticsCount in that same run reported 997 (the true diagnostic count before the 200-row write slice), not the count of rows actually written.
Budget behavior — read this before relying on a specific maxTotalChargeUsd value via the API. A positive maxTotalChargeUsd below the price of one assessment correctly delivers zero paid assessments and one free budget_stopped diagnostic (output example 3, $0.005 against a $0.0105 floor tier). Separately, the platform's own API-level cap-normalization behavior applies to a raw maxTotalChargeUsd: 0 value. A positive value below every tier's price only reliably guarantees zero paid results while the effective cap is preserved at that value — inspect the run's actual, effective options.maxTotalChargeUsd after it starts rather than assuming the requested value silently held.
Delivery is not transactional, and a FAILED run does not mean "nothing happened." Each assessment is written to the Dataset and then charged as two separate steps; a run failing between them can leave an effectively free, unconfirmed write with no automatic retry — no cross-run exactly-once guarantee. If pricing/charge-limit/event-counter state can't be verified mid-run, the run finalizes FAILED with a safe explanation. Inspect the original run's Dataset and OUTPUT/SUMMARY record before a manual retry — a blind retry after an ambiguous outcome can duplicate paid assessments.
Field values are read from the actual output, never assumed from the schema description alone. Every field above was independently confirmed against this actor's own real evidence receipts — businessSummary.diagnosticsCount genuinely differs from free in the mixed-boundary receipt (997 vs 201), which only reading the real summary.json makes clear.
Decision routing
status: shared_brand_hostname_group,action: add_to_verification_queue— an observed hostname+brand correlation with at least one other place in this batch. Treat as a research lead to verify manually, not a confirmed relationship.status: standalone,action: no_action_standalone— a usable hostname and non-generic brand resolved, but no peer shares them in this batch — a real conclusion about this batch, not a claim of no chain relationships anywhere.status: excluded_platform,action: no_action_excluded_platform— the only website signal is a known shared platform; explicitly not usable as an ownership signal.partial: true— disclosed incompleteness, a duplicate-row conflict, a truncated/invalid field, or confidence below 1 — worth a second glance.- Free diagnostic rows (
found: false) — never an assessment.missing_identity/oversized_identitymean no stable identity;unresolved_relationshipmeans neither a usable hostname nor a usable, non-generic brand was available;invalid_domain_optionsmeans your playbook input was rejected;budget_stopped/max_results_reachedmean a run-level cap was hit.
Commercial playbooks
Multi-city account consolidation. Run a fresh multi-city Maps export through to surface which listings likely belong to the same account before your reps start outreach.
Franchise-development territory mapping. Group a competitor or partner brand's Maps footprint by hostname+brand to estimate branch density per territory, using groupSize as a rough count.
Data hygiene before CRM import. Flag shared_brand_hostname_group results for manual account-merge review, and duplicateSourceRowCount > 1 rows for de-duplication.
Custom exclusion tuning. Add industry-specific shared platforms via additionalExcludedHostnames so they never falsely inflate a groupSize.
Integration recipes

This Actor's ID is 6OruMc0vPsndzCW0s. A natural upstream source is Google Maps Scraper (Actor ID nwua9Gu5YrADL7ZDj); Google Maps Extractor works identically. The recipes below describe how to configure each mechanism — not a claim any specific trigger is currently installed. A saved public example is available in the Tasks tab.
1. Console-native chaining (no code). On your Maps-scraper's run page, open Integrations, add an "Actor/task run" action targeting this Actor, and let the Integrations UI populate the payload with {{resource.defaultDatasetId}}. Map that value explicitly into this Actor's datasetId field — not the generic payload pass-through alone — to request a READ grant. The mapping requests the grant; it does not itself guarantee the platform honors it.
2. Direct webhook (POST /v2/webhooks), full recipe. Configure a webhook on your upstream Maps-scraper for the ACTOR.RUN.SUCCEEDED event, scoped to that specific Actor via its condition.actorId, targeting this Actor's run-creation endpoint:
{"isApifyIntegration": true,"shouldInterpolateStrings": true,"eventTypes": ["ACTOR.RUN.SUCCEEDED"],"condition": { "actorId": "nwua9Gu5YrADL7ZDj" },"requestUrl": "https://api.apify.com/v2/acts/6OruMc0vPsndzCW0s/runs?maxTotalChargeUsd=0.50","doNotRetry": true,"headersTemplate": "{\"Authorization\": \"Bearer {{APIFY_TOKEN}}\"}","payloadTemplate": "{\"datasetId\": \"{{resource.defaultDatasetId}}\", \"payload\": {\"resource\": {\"defaultDatasetId\": \"{{resource.defaultDatasetId}}\", \"id\": \"{{resource.id}}\"}}, \"brandCompatibilityMode\": \"exact\"}"}
Submit this to POST https://api.apify.com/v2/webhooks with your own Authorization: Bearer <APIFY_TOKEN> header. The {{APIFY_TOKEN}} placeholder is not an implicit platform template variable resolved automatically at trigger time — it must be provisioned separately: filled in from your own secrets store at the moment you register the webhook. doNotRetry: true avoids a duplicate-charge risk from an automatic retry. datasetId is set explicitly — that field is what actually requests access; payload.resource alone is informational provenance only. A positive maxTotalChargeUsd on the request URL caps spend; avoid literal 0 (see Evidence and boundaries).
3. Generic automation tools (n8n, Zapier, Make, or your own tooling). Use a generic Apify/HTTP node to call run-sync-get-dataset-items on this Actor's ID with a JSON body containing datasetId and your chosen brandCompatibilityMode/additionalExcludedHostnames/maxResults — a plain authenticated HTTPS POST, no native integration required.
4. Export / CRM-adjacent mapping. Every run's Dataset supports CSV/Excel/JSON export via the Console or GET /v2/datasets/{datasetId}/items?format=csv. entityId, title, status, groupKey, groupSize, and hostname alone drive a working worksheet.
Operating guide
- Confirm your source batch shape first. Spot-check rows against Input contract — each needs
placeIdorcid, and awebsite/titlepair for any grouping signal at all. - Choose
brandCompatibilityModedeliberately.prefix(default) catches more real variants but can occasionally overmatch;exactis stricter. - Add your own excluded hostnames if your industry has a common shared platform not in the built-in list — otherwise it can inflate
groupSizefalsely. - Start with a small
maxResultson a new source to confirm output matches expectations before a full batch. - Set
maxTotalChargeUsdvia the platform run option, and check its effective value after the run starts. For a guaranteed-zero-paid API call, use a small positive value below $0.0105, not literal0. - Read the
OUTPUT/SUMMARYrecord, not just the Dataset row count — rememberbusinessSummary.diagnosticsCountcounts only this product's own diagnostics, not run-level notices layered on top. - If a run finalizes
FAILED, inspect its Dataset andOUTPUTrecord before retrying — a blind retry can duplicate already-successful paid assessments. - Re-running is always new billing — no cross-run dedup key.
FAQ
Does this Actor scrape Google Maps itself? No. It only reads an existing places batch you or an upstream Actor already collected — no open-web fetch, no upstream Actor started.
Can I use a source other than Google Maps Scraper? Yes — any source producing rows with placeId/cid, website, and title works, including Google Maps Extractor.
Does a shared_brand_hostname_group match mean these locations are confirmed corporately owned by the same company? No. It means this batch's rows share a non-excluded hostname under a compatible normalized brand — an observed, batch-local correlation flagged for your own verification, never a legal-ownership or franchise-registry confirmation.
Does a shared phone number group two locations? No. Only hostname + compatible brand link locations; phone, address, and category are pass-through fields only.
Why is a brand sometimes rejected even though the title looks fine? If the normalized brand reduces to only generic words ("Store", "The Shop"), it carries no distinguishing information and can't support grouping — it's a free unresolved_relationship diagnostic instead.
Does re-running the same batch avoid double-billing? No. Every run is independent with no cross-run deduplication.
Is the "partial-columns" style example a demonstration of missing upstream data? No — see output example 2. Same real values as the healthy example; only partial/found are deliberately set as a controlled test.
Why did one run report 201 free rows against a "200 diagnostics" cap, and diagnosticsCount showed 997? A separate run-level notice (max_results_reached) is written on top of the 200-row cap, not counted against it. businessSummary.diagnosticsCount reported the true 997 count before that write slice — see Evidence and boundaries.
Sources and rights
This Actor reads only Datasets and runs your own Apify account token already has permission to read; it never uses another user's credentials and never starts an upstream Actor on your behalf. The public prefilled example is a curated, non-sensitive real snapshot of three London museum listings from a Google Maps scrape (sourceActorId nwua9Gu5YrADL7ZDj, sourceRunId 5VeVFvxu0MQhPBrWY, sourceDatasetId WJkSy7pXIRxQlwPvn), observed 2026-09-22, reused with its original values unchanged — a saved example, not a new fresh scrape. All output values here are copied verbatim from real runs of this Actor's accepted build; none are invented. These verification runs were the product owner's own cost, not customer sales or revenue evidence.
Related tools
Related tools for adjacent workflows in B2B lead generation and data enrichment.
| Actor | What it does |
|---|---|
| Company Lookup — Website & GLEIF Evidence | Pair it in the B2B lead generation and data enrichment workflow: Turn domains, company names, or exact LEIs into evidence-linked website and GLEIF observations with... |
| Company Registry Enricher | Pair it in the B2B lead generation and data enrichment workflow: Resolve company names, LEIs, or UK company numbers against official GLEIF and optional Companies House data |
| Lead List Cleaner - Dedupe, DNS & Email Hygiene | Pair it in the B2B lead generation and data enrichment workflow: Normalize mixed lead rows, deduplicate domains and emails, check DNS/MX evidence, score retained leads,... |
| Google Maps Scraper | Collect upstream place rows. Check field compatibility before passing a saved Dataset to this Actor. | | Google Maps Reviews Scraper | Collect a separate review dataset for location research; review rows require their own workflow and are not chain-grouping inputs. |