If you need names, addresses, ratings, phone numbers and coordinates for local businesses across a set of areas, you can get all of it with one POST request per page — no browser, no scraping. I pull this data from the maps endpoints of a SERP API and flatten it into a CSV. Here's the whole flow in Python: search an area, collect the places, dedupe, export, then enrich the rows you actually care about.
The two endpoints
-
POST /google/maps/search— takesq, plus optionalhl,gl,pageand coordinates (lat+lngtogether, withzoomfrom 1 to 21, defaulting to 14). Returns aplacesarray. -
POST /google/maps/detail— takes afeature_idfrom a search result. Returns oneplace, and itspageis always 1.
Each place object documents these fields: position (search results only), name and its alias title, feature_id (with data_id as an alias), place_id, cid, kgmid, google_maps_url, url, rating, types, category, address, address_components, plus_code, phone, phone_international, phone_uri, website, website_domain, latitude, longitude, photos, hours, open_status, attributes, short_description, description, snippet, timezone, region, country_code, language.
Both maps endpoints bill 2 credits per successful request, where a web search request bills 1. New accounts get 100 free searches, which is enough to inspect the response shape before committing to anything.
Step 1: search one area
import time
import requests
API_URL = "https://api.serpbase.dev/google/maps/search"
API_KEY = "YOUR_API_KEY"
def search_places(query: str, page: int = 1, center=None) -> list:
body = {"q": query, "hl": "en", "gl": "us", "page": page}
if center:
lat, lng, zoom = center
body.update({"lat": lat, "lng": lng, "zoom": zoom})
resp = requests.post(
API_URL,
headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
json=body,
timeout=30,
)
resp.raise_for_status()
return resp.json().get("places", [])
rows = []
for page in range(1, 4):
places = search_places(
"coffee shops in Austin", page=page, center=(30.2672, -97.7431, 13)
)
if not places:
break
rows.extend(places)
time.sleep(1)
print(len(rows), "places")
Every field name above, and the request shapes in this post, come from the SerpBase Maps API documentation — it's also where the error codes live, which matter in step 3.
On failures the API returns a JSON body carrying status and error. The documented codes: 1000 INVALID_REQUEST, 1001 UNAUTHORIZED, 1020 INSUFFICIENT_CREDITS, 1029 RATE_LIMITED, 1500 INTERNAL_ERROR, 1502 UPSTREAM_FAILED, 1503 SERVICE_UNAVAILABLE, 1504 UPSTREAM_TIMEOUT. The 15xx family is worth exactly one retry; 1029 means slow down.
Step 2: dedupe and flatten to CSV
The same business can appear on more than one page, so dedupe on feature_id before writing anything:
import csv
FIELDS = ["name", "category", "address", "phone", "website", "rating",
"latitude", "longitude", "feature_id", "google_maps_url"]
def to_row(place: dict) -> dict:
return {key: place.get(key, "") for key in FIELDS}
seen, clean = set(), []
for place in rows:
identifier = place.get("feature_id") or place.get("name")
if identifier in seen:
continue
seen.add(identifier)
clean.append(place)
with open("places.csv", "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS)
writer.writeheader()
writer.writerows(to_row(place) for place in clean)
Example rows (illustration, not real data):
name,category,address,phone,rating,feature_id
Example Coffee Roasters,Coffee shop,"100 Congress Ave, Austin, TX",+1 512-555-0100,4.6,0x8644b5...
Second Example Cafe,Coffee shop,"200 6th St, Austin, TX",+1 512-555-0142,4.4,0x8644b5...
rating, hours and open_status are point-in-time values — if you store them, store the fetch date next to them.
Step 3: enrich the rows you care about
Search results already carry a lot, but you can pull one place on its own with the detail endpoint:
DETAIL_URL = "https://api.serpbase.dev/google/maps/detail"
def fetch_detail(feature_id: str):
resp = requests.post(
DETAIL_URL,
headers={"X-API-Key": API_KEY, "Content-Type": "application/json"},
json={"feature_id": feature_id, "hl": "en", "gl": "us"},
timeout=30,
)
data = resp.json()
if data.get("status") == 1004: # NOT_FOUND: well-formed but unknown id
return None
return data.get("place")
for place in clean[:5]:
detail = fetch_detail(place["feature_id"])
if detail:
print(detail.get("name"), "|", detail.get("website_domain"),
"|", detail.get("short_description"))
time.sleep(1)
The docs note that a well-formed but unknown feature_id comes back as 1004 NOT_FOUND, so the helper returns None instead of blowing up. That happens when a listing disappears between the search and the detail call.
What this costs
Both maps endpoints are 2 credits per successful request, and credits_charged in the response tells you what each call actually cost. A sweep of 25 areas with 3 pages each is 75 requests, so budget around 150 credits. Failed and upstream-timed-out requests are refunded automatically.
FAQ
Do I need coordinates? No — q alone works. Send lat and lng together (with zoom, 1–21) when you want the search centered on a specific neighborhood instead of a city name.
place_id, feature_id or data_id — which one do I store? The docs describe data_id as an alias for feature_id, and the detail endpoint is documented to take feature_id. I store that one and keep place_id/cid around as extra identifiers.
Are ratings current? They're whatever the data source returned at request time. Snapshot them per fetch with a date column rather than treating the CSV as a live source of truth.
Point it at your own area list and run it once — the CSV is the fastest way to see which fields come back populated for your market. All the parameters used here are in the documentation linked above.
Top comments (0)