Business Listings API in Python: Build a Prospect List Fast
A business listings API turns a category and a city into JSON records with the same fields in the same places: name, phone_e164, website, rating, address and, with a second call, the role mailboxes and social profiles from the business website. The script at the end of this page pages a search, deduplicates on place_id, fetches details only for records with a website, handles a 429 and a 402, and writes a CSV. It runs on the free plan with httpx and nothing else.
What a business listings API gives you that a spreadsheet does not
Most prospect lists start the same way: someone opens a map, searches "plumber", and copies names and phone numbers into a sheet. It works for thirty rows and fails at two hundred: the data is stale and nobody knows which rows have been contacted.
A business listings API replaces the copying step with a request. You send a keyword and a location and get back a JSON list where every record has the same fields in the same places. Once the data has a fixed shape, Python can filter it, deduplicate it, join it to what you already hold, and write it out in whatever format the next tool wants.
The examples use Locomint because its details endpoint also reads the business website and returns role mailboxes, social profiles and WhatsApp numbers in the same record.
Setting up Python and your API key
You need Python 3.10 or newer and the httpx library, which handles connection reuse and timeouts more predictably than the standard library. Install it, then store your key in an environment variable so it never ends up inside a script that gets shared.
python -m pip install httpx
export LOCOMINT_KEY="lm_free_your_key_here"
Get the key from the signup page. The free plan gives you 200 credits a month and 60 requests a minute with no card, which is enough to run everything in this article once. On Windows, use set LOCOMINT_KEY=... in Command Prompt or $env:LOCOMINT_KEY = "..." in PowerShell.
Every request carries the key in an X-API-Key header. There are no tokens to refresh. A missing or unknown key returns 401 with the error code unauthorized, and every error body has the same shape: {"error": {"code", "message", "request_id"}}.
Your first search request
The search endpoint takes a keyword in q, a place in location, and a page size in limit (default 20). Write the location as "City, Country". In our daily canary runs a bare "Warsaw" resolved to Warsaw, Indiana; "Warsaw, Poland" cannot. Try the request with curl first to see a real response before writing any Python.
curl "https://api.locomint.io/v1/places/search?q=plumber&location=Toronto,%20Canada&limit=5" \
-H "X-API-Key: $LOCOMINT_KEY"
The same call in Python is a few lines. This is a complete script: save it, run it, and it prints five businesses.
import os
import httpx
API = "https://api.locomint.io/v1"
headers = {"X-API-Key": os.environ["LOCOMINT_KEY"]}
with httpx.Client(base_url=API, headers=headers, timeout=30) as client:
resp = client.get("/places/search", params={
"q": "plumber",
"location": "Toronto, Canada",
"limit": 5,
})
resp.raise_for_status()
body = resp.json()
for place in body["data"]:
print(place["place_id"], place["name"], place.get("phone_e164"), place.get("rating"))
print("more pages:", body["pagination"]["next_page"],
"credits used:", body["meta"]["credits_used"])
raise_for_status() turns a 401 or a 402 into an exception, so a bad key or an exhausted quota stops the script instead of producing an empty file. The with block keeps one connection open for every request, which matters once you are making a few hundred calls.
Reading the response: the summary fields a prospect list needs
The search response has three top-level keys. data is the list of places, pagination tells you whether there is a next page, and meta carries the request ID and the credits the call consumed. Each item in data is a place summary with the core fields. The record reference lists all of them; these are the ones a prospect list needs.
| Field | Type | Use it for |
|---|---|---|
place_id | string | Stable key. Deduplicate on it and use it to fetch details. |
name | string | Display name as listed |
phone, phone_e164 | string or null | phone as displayed locally; phone_e164 normalised with the country code, for dialling and deduplication |
website | string or null | Decides whether a details call can add contacts |
rating, review_count | number, int | Filter out unrated or poorly rated businesses |
address.full, address.city, address.country_code | strings | Segment the list by area; the country code is ISO 3166-1 |
categories | list | Drop results that matched the keyword loosely |
status | string | open, temporarily_closed or permanently_closed |
Treat phone, phone_e164 and website as optional. Plenty of real businesses list none of them, and place["website"].lower() raises on the first one. Use .get() and test for None. price_level exists in the record but is rarely populated by the source; do not build a filter on it.
Service-area businesses, such as mobile mechanics or cleaners with no shopfront, come back with service_area_business set to true and an empty address.full. They are still valid prospects; just do not filter them out because the street is missing.
Fetching full records with website contacts
The search summary is enough for a calling list. For email outreach you need the details endpoint, which returns everything in the summary plus the description, all seven days of opening hours, and an enrichment block filled in from the business website. The enrichment fields are:
enrichment.emails: role mailboxes on the company domain such as info@ or bookings@. Person-named addresses are dropped before the response is built.enrichment.socials: an object of profile URLs keyed by network.enrichment.whatsapp: a number taken from a click-to-chat link on the site, when there is one.enrichment.website_status:ok,unreachable,parked,redirect_socialorno_website. This is why a record has no contacts.redirect_socialmeans the listed website redirected to a social profile; the profile URL lands insocialsand nothing is crawled.enrichment.crawled_at: when the site was last read. Keep it in your table so you can answer "how old is this?" later.
A details call costs one credit. The first time a website is read it adds roughly two seconds to the call, with a 12-second budget after which the site is marked unreachable. The result is cached per site for 30 days, so a second run over the same list is quick and re-crawls nothing; pass refresh=true to force a crawl, at one extra credit.
import os
import httpx
API = "https://api.locomint.io/v1"
headers = {"X-API-Key": os.environ["LOCOMINT_KEY"]}
with httpx.Client(base_url=API, headers=headers, timeout=30) as client:
place = client.get("/places/ChIJjxN4B_tDXz4RLrBrwzc9Qew")
place.raise_for_status()
record = place.json()
enrichment = record.get("enrichment") or {}
print(record["name"])
print("status: ", enrichment.get("website_status"))
print("emails: ", enrichment.get("emails", []))
print("socials: ", enrichment.get("socials", {}))
print("whatsapp:", enrichment.get("whatsapp"))
Practical tip: only call details for records whose summary has a website. A business with no website cannot gain an email, a social profile or a WhatsApp number, so the call would spend a credit to return what you already have. On a 200-credit free month that filter is the difference between one city and two.
The complete prospect list script
The script below pages through the search until it has the number of businesses you asked for, deduplicates on place_id, fetches details for the ones with a website, and writes one CSV row per business. It handles the two error responses you will meet in practice. A 429 with code rate_limited arrives when you exceed 60 requests a minute; the script sleeps for the number of seconds in Retry-After. A 402 with code quota_exceeded arrives when the request would exceed the monthly quota; nothing is charged for that request, and the script stops rather than looping.
import csv
import os
import sys
import time
import httpx
API = "https://api.locomint.io/v1"
KEY = os.environ["LOCOMINT_KEY"]
QUERY = "plumber"
LOCATION = "Toronto, Canada"
TARGET = 100
OUTPUT = "prospects.csv"
def get(client, path, **params):
"""GET with retry on 429 and a clean stop on quota exhaustion."""
while True:
resp = client.get(path, params=params)
if resp.status_code == 429:
wait = int(resp.headers.get("Retry-After", "5"))
print(f"rate limited, waiting {wait}s", file=sys.stderr)
time.sleep(wait)
continue
if resp.status_code == 402:
sys.exit("monthly quota reached; nothing was charged for this call")
resp.raise_for_status()
return resp.json()
def search(client):
"""Yield place summaries page by page until the API has no more."""
page = 1
while True:
body = get(client, "/places/search",
q=QUERY, location=LOCATION, limit=50, page=page)
yield from body["data"]
if not body["pagination"]["next_page"]:
return
page += 1
def row_for(summary, record):
address = summary.get("address") or {}
enrichment = (record or {}).get("enrichment") or {}
socials = enrichment.get("socials") or {}
return [
summary["place_id"],
summary["name"],
summary.get("phone_e164") or summary.get("phone") or "",
summary.get("website") or "",
summary.get("rating") or "",
summary.get("review_count") or "",
address.get("city") or "",
address.get("country_code") or "",
"; ".join(enrichment.get("emails") or []),
enrichment.get("whatsapp") or "",
socials.get("instagram") or "",
socials.get("facebook") or "",
enrichment.get("website_status") or "",
]
headers = ["place_id", "name", "phone", "website", "rating", "reviews",
"city", "country", "emails", "whatsapp", "instagram", "facebook",
"website_status"]
seen = set()
with httpx.Client(base_url=API, headers={"X-API-Key": KEY}, timeout=30) as client, \
open(OUTPUT, "w", newline="", encoding="utf-8") as f:
writer = csv.writer(f)
writer.writerow(headers)
for summary in search(client):
if summary["place_id"] in seen:
continue
seen.add(summary["place_id"])
if summary.get("status") == "permanently_closed":
continue
record = None
if summary.get("website"):
record = get(client, f"/places/{summary['place_id']}")
writer.writerow(row_for(summary, record))
if len(seen) >= TARGET:
break
print(f"wrote {len(seen)} businesses to {OUTPUT}")
Change QUERY, LOCATION and TARGET at the top and the rest stays the same. The socials object is keyed by network name, so if you care about a different one, add it to row_for. Everything in the CSV comes straight from the API; no person-named address can appear because the server dropped it.
Filtering the list and scaling to more cities
Most of the credits in a prospect list go on details calls, so filter on the summary fields you have already paid for before making them. Skip anything whose status is not open; a closed business will not answer. Set a floor on rating or review_count if you sell something only established businesses buy. Check categories when your keyword is broad: a search for "clinic" returns veterinary clinics alongside medical ones, and the categories list is where you tell them apart.
To size a search before paying for it, call the free count endpoint with the same q and location. It returns the size of the first result page, up to 50, with exact: true when that is the whole answer and at_least: true when a full search will find more. Each key gets 50 fresh counts a day; repeats within 24 hours are served from cache.
The script takes one location. To cover several, wrap it in a loop over a list of "City, Country" strings and keep the seen set shared across them, so a business that appears in two neighbouring searches is written once. Keep the loop sequential: the rate limit is 60 requests a minute per key, and parallel workers gain nothing except more 429 responses to wait on.
For a single large city, one keyword search can page out before it has covered every district. The article on scraping a whole city without missing businesses explains the drift you will see on deep pages and how to search district by district instead. And once your list has email addresses, run them through the verify endpoint before they go into a mailing tool; it accepts 100 addresses per call and costs one credit each.
Where to start this week
Run the first script as written, with limit=5, and read the raw JSON for a business you know. Check that the phone and website match. Then run the full script with TARGET = 50 for one category in one city and open the CSV. Count how many rows have a website, how many of those returned an email, and how many are unreachable or parked. Those three numbers are the yield you can expect before you scale to a second city or move to a paid plan on the pricing page. GET /v1/usage, which is free, tells you how many credits the run cost.
If you already have a list of businesses and only need the contact details, the details endpoint accepts a place_id directly, so you can skip the search step entirely. And if you are choosing between this and the official mapping platform's SDK, the comparison in Google Places API pricing explained covers where each one fits.
Frequently asked questions
Do I need a Google Maps API key to run these Python examples?
No. The examples call the Locomint API with a single X-API-Key header. A free key gives you 200 credits a month and needs no card. The place_id values Locomint returns are the same identifiers used by the official Google Places API, so if you already have a pipeline built on those ids the records line up.
How many credits does a 100-business prospect list cost?
One credit per place returned by search plus one credit per details call. If you fetch details only for the businesses that list a website, a 100-place search that yields 60 websites costs about 160 credits. The free plan covers 200 credits a month, so one list of that size fits without paying.
Why does the script skip businesses without a website?
The enrichment block, which holds emails, social profiles and WhatsApp numbers, is built by reading the business website. A record with no website cannot gain any of those fields, so fetching its details costs a credit and returns nothing you did not already have from the search summary. Keep the phone number from the summary instead.