The Local-SEO Agency's Review Watch: Get Pinged the Minute a Client Gets a 1-Star
If you run a local-SEO or reputation agency, your unit of work is not a website — it is a roster of Google Business Profiles you are accountable for. A roofer in one city, three dental offices in another, a regional HVAC chain with fourteen locations. Every one of those listings can pick up a review at any hour of the day, and a single fresh 1-star, left unanswered, is the thing that shows up in the client's next QBR as "why didn't you catch this?" The value you sell is response speed, and you cannot deliver response speed by manually opening forty Business Profile dashboards every morning.
This guide is the review-watch pipeline that replaces that manual sweep. We will cover why new-review detection is a different problem from rating tracking, how to resolve a client's Maps listing to a stable identifier, how to pull recent reviews per location, poll and collect across the roster, diff against the set of review ids you have already seen so you surface only net-new reviews, flag anything at one or two stars, and route an alert to the account manager who owns that client. The worked example is a small roster of local businesses, but the same loop scales from three clients to three hundred by adding rows to a list.
Why New-Review Detection Is Its Own Problem
The instinct is to track each client's star rating and alert when it drops. That is necessary but not sufficient, and understanding why is the whole design.
A Google listing's aggregate rating is an average over its entire review history. For a mature business with 400 reviews sitting at 4.8 stars, one new 1-star review drags the average to roughly 4.79 — a change too small to cross any sane threshold. The aggregate alert stays silent, the review sits unanswered, and you have missed exactly the event you were hired to catch. Aggregate rating tracking only reliably fires for low-volume listings or for a sustained run of bad reviews; it is a reputation-drift signal, not a new-review signal.
What you actually need is event detection: a new review just landed, here is its content, here is its star rating. That is a diffing problem, not an averaging problem. Each review carries a stable review id, so if you keep the set of ids you have already seen and re-pull the recent reviews on a schedule, the net-new reviews are simply the ids that were not in your set last cycle. Their star rating tells you whether to escalate. This catches the single 1-star on a 400-review listing that the aggregate never would.
So the watch has two layers. The aggregate rating alert covers slow erosion across a client's reputation. The review-id diff covers the per-review "respond now" trigger. Both run on the same roster; they answer different questions, and a serious agency runs both.
Step 1: Resolve Each Client to Its Listing
Before you can watch a client you need a stable handle on their listing. The Google Maps place endpoint takes a Maps place URL — or a place query — and returns the resolved listing with its identifiers, so you can confirm you are watching the right location and not a similarly-named business two suburbs over. This matters most for multi-location clients, where "Smile Dental" might have five branches that each need their own watch.
Every call is asynchronous: you submit, get a job id back, then poll. Confirm one client resolves with curl before you wire up the roster:
# 1) Resolve a client's listing — returns a job id immediately
curl -G "https://api.logposervices.com/api/v1/ecommerce/googlemaps/place" \
-H "X-API-Key: lp_xxxxxxx" \
--data-urlencode "url=https://www.google.com/maps/place/Acme+Dental+Austin"
# → {"job_id": "gm_3c91...", "status": "pending"}
# 2) Poll the job until status == "completed"
curl -H "X-API-Key: lp_xxxxxxx" \
https://api.logposervices.com/api/v1/jobs/gm_3c91
# 3) Fetch the resolved listing
curl -H "X-API-Key: lp_xxxxxxx" \
https://api.logposervices.com/api/v1/jobs/gm_3c91/result
api.logposervices.com sits behind Cloudflare, which kills any single connection at roughly 90 seconds. Even a fast resolve should go through the submit-then-poll pattern so you never depend on an inline request finishing inside that window. The place result gives you the canonical Maps URL for the listing — that canonical URL is what you store per client and reuse for every reviews pull, so a typo in the client's input URL gets normalized once, up front, instead of silently watching the wrong place forever.
Step 2: Pull Recent Reviews for a Listing
The Google Maps reviews endpoint takes the place URL and returns the recent reviews on that listing — for each one, the author, the star rating, the review text, a relative and absolute time, and a review id. That review id is the load-bearing field for this whole pipeline: it is what lets you tell a review you have already seen from one that just landed.
# Submit a reviews pull for one client listing
curl -G "https://api.logposervices.com/api/v1/ecommerce/googlemaps/reviews" \
-H "X-API-Key: lp_xxxxxxx" \
--data-urlencode "url=https://www.google.com/maps/place/Acme+Dental+Austin"
# → {"job_id": "gm_77b2...", "status": "pending"}
# Poll, then fetch the reviews
curl -H "X-API-Key: lp_xxxxxxx" \
https://api.logposervices.com/api/v1/jobs/gm_77b2/result
The reviews endpoint returns the most recent reviews, which is exactly the slice you want for a watch — you are never trying to backfill a listing's entire ten-year review history, you are asking "what is new since I last looked?" Because Google surfaces newest-first, a modest pull each cycle reliably contains anything that landed since your last check, as long as your check interval is tighter than the rate at which a busy client accumulates reviews. For most local businesses, checking every few hours is far more frequent than reviews arrive, so nothing slips between cycles.
Step 3: Watch the Whole Roster, Then Poll
A real agency does not watch one listing — it watches the roster. The right pattern is the same fire-all-then-poll shape you would use for any batch: submit a reviews job for every client up front (each returns instantly with a job id), then poll the outstanding ids until they all finish. The jobs run concurrently server-side, so a forty-client sweep is a few minutes of wall-clock time, not forty sequential waits.
import os, time, requests
API_KEY = os.environ["LOGPOSE_API_KEY"]
BASE = "https://api.logposervices.com/api/v1"
HEADERS = {"X-API-Key": API_KEY}
# Your client roster: each entry is the canonical place URL from Step 1,
# plus the account manager who owns the relationship.
ROSTER = [
{"client": "Acme Dental", "am": "dana",
"url": "https://www.google.com/maps/place/Acme+Dental+Austin"},
{"client": "Northside Roofing", "am": "marcus",
"url": "https://www.google.com/maps/place/Northside+Roofing+Phoenix"},
# ... one row per location across the roster
]
def submit(url):
r = requests.get(
f"{BASE}/ecommerce/googlemaps/reviews",
params={"url": url},
headers=HEADERS, timeout=30,
)
r.raise_for_status()
return r.json()["job_id"]
def collect(job_map, poll_every=5, timeout_s=600):
"""job_map: {job_id: client_dict}. Returns {job_id: [reviews]}."""
pending = set(job_map)
results, deadline = {}, time.time() + timeout_s
while pending and time.time() < deadline:
for jid in list(pending):
s = requests.get(f"{BASE}/jobs/{jid}", headers=HEADERS, timeout=15).json()
status = s.get("status")
if status == "completed":
res = requests.get(f"{BASE}/jobs/{jid}/result",
headers=HEADERS, timeout=30).json()
results[jid] = res.get("reviews", [])
pending.discard(jid)
elif status == "failed":
print(f" reviews job {jid} failed: {s.get('error')}")
pending.discard(jid)
if pending:
time.sleep(poll_every)
return results
# Submit the whole roster, then poll it
job_map = {submit(c["url"]): c for c in ROSTER}
print(f"submitted {len(job_map)} client review pulls")
fresh = collect(job_map)
Submitting first and polling second is what keeps a large roster fast: the server runs the pulls in parallel up to your account's concurrency cap, and your script just watches the queue drain. Each client's reviews come back keyed to the job id, which you mapped back to the client and their account manager, so you never lose track of which reviews belong to whom.
Step 4: Diff by Review ID to Find Net-New Reviews
This is the core of the watch. For each client you keep a stored set of review ids you have already processed. After a fresh pull, the net-new reviews are the ones whose id is not in that set. Diffing by id — not by author name or by text — is what makes this reliable: two different customers can leave reviews with identical short text ("Great service!"), and the same author can appear across multiple listings, but each review has its own id.
import json, pathlib
STATE = pathlib.Path("seen_reviews.json")
def load_state():
if STATE.exists():
return {k: set(v) for k, v in json.loads(STATE.read_text()).items()}
return {}
def save_state(state):
STATE.write_text(json.dumps({k: sorted(v) for k, v in state.items()}))
def net_new(client_key, reviews, state):
"""Return reviews whose id we have not seen for this client before."""
seen = state.setdefault(client_key, set())
new = []
for rv in reviews:
rid = rv.get("review_id")
if not rid or rid in seen:
continue
seen.add(rid)
new.append(rv)
return new
state = load_state()
alerts = []
for jid, reviews in fresh.items():
client = job_map[jid]
new = net_new(client["client"], reviews, state)
for rv in new:
stars = rv.get("rating") or rv.get("stars") or 0
if stars <= 2: # the escalation trigger
alerts.append({**client, "review": rv, "stars": stars})
save_state(state)
print(f"{len(alerts)} new low-star reviews across the roster")
Two details earn their place. The first run seeds the state with everything currently on each listing and produces no alerts — that is correct, because on day one nothing is "new"; the watch only becomes meaningful from the second cycle onward. And the stars <= 2 filter is the line between new review and new problem: every net-new review updates state, but only one- and two-star reviews become an alert the account manager has to act on. A fresh 5-star is good news that needs no SLA response.
Step 5: Route the Alert to the Right Account Manager
A detection that lands in a log nobody reads is worthless. The last step turns each flagged review into a message that reaches the specific account manager who owns that client, fast enough to respond inside the SLA window you promised. Keep the alert payload tight — who, where, how bad, and the text — so the AM can triage from the notification without opening anything.
def format_alert(a):
rv = a["review"]
return (
f":rotating_light: New {a['stars']}-star for *{a['client']}*\n"
f"AM: {a['am']}\n"
f"Author: {rv.get('author', 'unknown')} ({rv.get('relative_time', '')})\n"
f"\"{(rv.get('text') or '').strip()[:300]}\"\n"
f"Respond via the client's Google Business Profile dashboard."
)
for a in alerts:
msg = format_alert(a)
# route to the AM's channel — Slack, Telegram, email, whatever they live in
print(msg)
print("-" * 40)
Note the last line of the alert text. The pipeline detects; it does not respond. The actual reply is posted by the business through its official Google Business Profile, where the owner can verify the reviewer and reply in their own voice. Conflating detection with response is how agencies get themselves into trouble — keep the read-only watch and the first-party response cleanly separated, and the alert's only job is to get a human to the dashboard quickly.
Scaling This Across a Growing Roster
The loop above is a script you run on a schedule — submit the roster, diff, alert, repeat. That works, but it means you own a cron job, a state file, and the babysitting that comes with both. As the roster grows, two things make a standing watch practical without hosting that yourself.
First, the aggregate side. LogPose exposes a monitor primitive that polls a saved listing on a schedule and fires when a metric crosses a threshold. For the reputation-drift layer, you create one monitor per client location:
curl -X POST "https://api.logposervices.com/api/v1/monitors" \
-H "X-API-Key: lp_xxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.google.com/maps/place/Acme+Dental+Austin",
"name": "Acme Dental — rating watch",
"metric": "rating",
"condition": "drops_below",
"threshold": 4.0,
"check_interval_hours": 6,
"notify_channels": ["slack"]
}'
notify_channels accepts email, webhook, telegram, slack, and discord, so each monitor can ping the channel the responsible account manager actually lives in — route the dentist's AM to one Slack channel and the roofer's AM to a Telegram chat. One monitor per location across the whole roster gives you the slow-bleed reputation alert with no scheduler to run.
Second, be clear about the division of labor. The monitor's metric rating / condition drops_below covers the aggregate alert — the right trigger when a listing's average has measurably slipped. It does not catch the single 1-star buried under hundreds of 5-stars, because that does not move the average. That per-review case is exactly what the review-id diff from Step 4 is for. The honest setup is both: a monitor per location for aggregate drift, plus the diffing loop for net-new per-review detection, with both feeding alerts into the same per-AM channels. Together they remove the manual dashboard sweep entirely.
The Honest Fit
This approach fits well when you manage a roster of Google Business Profiles and your job is proactive review response — you want to know within hours, not days, when any client picks up a bad review, and you want that alert to reach the right account manager automatically. The reviews endpoint, the review-id diff, and the per-location monitor are the three primitives that turn a manual morning sweep into a standing watch.
Where it is not the right tool: this is a detection-and-alert pipeline, not a full reputation-management suite. It does not template or post your responses, it does not run sentiment dashboards or trend analytics across your book of business, and it watches Google only — if a client's reputation also lives on Yelp, Facebook, or industry-specific review sites, those are not covered here. The realistic shape is to use this as the fast, reliable Google-Maps detection layer and pair it with a response and reporting tool for the rest. Detection speed is the part that is hard to do well at roster scale, and it is the part this pipeline owns.
Get Started
- Sign up at logposervices.com and generate an API key under Tool → API Keys.
export LOGPOSE_API_KEY=lp_xxxxxxx- Resolve one client listing, then pull its reviews:
curl -G "https://api.logposervices.com/api/v1/ecommerce/googlemaps/reviews" \
-H "X-API-Key: lp_xxxxxxx" \
--data-urlencode "url=https://www.google.com/maps/place/Acme+Dental+Austin"
Then build the roster list, submit one /api/v1/ecommerce/googlemaps/reviews?url=... job per client, diff each pull by review id against your stored set, flag anything at one or two stars, and route the alert to the account manager who owns that client. Add a monitor per location for the aggregate rating-drop layer, and you have proactive review response across the whole book of business. Results from any job can also be exported straight to CSV from the dashboard if you want a record outside the alert stream.
Related reading: Outscraper alternatives for Google Maps reviews for the reviews-extraction landscape, How to scrape Google Maps for local business leads for the single-listing fundamentals, and How to build a service-area lead list from Google Maps for the metro-wide grid pattern.
External: Google Maps, hiQ Labs v. LinkedIn.