How to find local businesses without a website
If you build websites, run ads or sell booking software, the best prospects are the businesses that still have no site of their own. This guide shows how to list every dentist, plumber or hairdresser in a city that has a phone number but no website, in one API call, and save the result as a CSV.
Why this is hard to do by hand
Map apps are built for finding one business, not a list of them. Scrolling a map, copying names into a spreadsheet and then checking each one for a website takes hours per city, and the result is out of date the week after. Search engines are worse: a business with no website is exactly the one that does not show up in organic results.
OpenStreetMap already holds most of this data. Shops, clinics and trades are mapped with their address, phone, opening hours and, when they have one, their website. The POST /v1/places endpoint queries that data for one category in one place and filters it for you, so "has a phone, has no website" is two parameters instead of a script.
The request
Send a category, a location (a city name such as "Austin, TX", or a bbox of [south, west, north, east] if you want an exact area), and the two filters. Each filter takes "any", "yes" or "no".
curl https://scrape.land/v1/places \
-H "X-Api-Key: YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"category": "dentist",
"location": "Austin, TX",
"website": "no",
"phone": "yes"}'The same call from Python, writing the rows straight to a CSV file:
import csv
import requests
r = requests.post(
"https://scrape.land/v1/places",
headers={"X-Api-Key": "YOUR_KEY"},
json={"category": "dentist", "location": "Austin, TX",
"website": "no", "phone": "yes"},
timeout=60,
)
r.raise_for_status()
body = r.json()
cols = ["name", "phone", "address", "city", "email", "opening_hours", "profile", "osm_url"]
with open("dentists-no-website.csv", "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=cols, extrasaction="ignore")
w.writeheader()
w.writerows(body["results"])
print(body["count"], "rows written;", body["matched"], "matched in total")
print(body["attribution"])What comes back
{
"category": "dentist",
"bbox": [30.098, -97.938, 30.516, -97.568],
"results": [
{
"name": "Example Family Dental",
"category": "dentist",
"address": "1200 Example St",
"city": "Austin",
"lat": 30.2711,
"lon": -97.7437,
"phone": "+1 512 555 0142",
"opening_hours": "Mo-Fr 08:00-17:00",
"osm_url": "https://www.openstreetmap.org/node/123456789"
}
],
"count": 1,
"matched": 1,
"units": 1,
"attribution": "© OpenStreetMap contributors, ODbL (openstreetmap.org/copyright)"
}A few fields are worth knowing about:
countis how many rows are in this response;matchedis how many businesses fit your filters in that area. Ifmatchedis larger, you hit the per-search limit.profileappears when the only "website" a business lists is a social, booking or directory page. Those are treated as a profile, not a website, because a Facebook page is usually a sign the business still needs a real site. That is often the list you want.osm_urllinks every row back to its OpenStreetMap object, so you can check it or fix it at the source.unitsis what the call cost, in request units.
Here is the same request run for real, with a limit of 20. Austin had seven mapped dentists with a phone number and no website, one of them listing only a Facebook page (which came back as profile):

"count": 7 and "units": 1. Email, latitude and longitude columns are left out of the picture.Other categories and bigger cities
Swap "dentist" for "plumber", "electrician", "hairdresser", "car_repair", "restaurant" and more. GET /v1/capabilities lists every category under entitlements.places.categories, so you can build a picker from it rather than hardcoding names.
A search covers roughly one metro area. If you ask for something much larger, such as a whole state, the API answers with a 400 asking for a smaller region. Split big areas into cities, or pass several bbox tiles one after the other. To cover a list of cities, loop over them and append each response to the same CSV.
Repeating a search you already ran recently is fast, because results are cached. If the map data service is busy you get a 502 that says so, and you are not billed for it: wait a few seconds and send it again.
Using the data responsibly
The data comes from OpenStreetMap under the Open Database License (ODbL). Keep the attribution string with the data when you publish or share it. These are businesses, but a sole trader's phone number can still be personal data in some countries, so check the rules where you and they are before you start calling. Our Acceptable Use Policy applies as well.
What it costs
1 request unit per 10 rows returned (at least 1 per search). The Free plan (1,000 requests a month, no card) returns a 10-row preview per search; paid plans return up to 200 rows per search. See pricing.
Next steps
Once you have the list, the directory to lead list guide shows how to enrich rows that do have a site. The full parameter reference is in the docs. To try it, create a free account and paste the curl command above with your key.