Tutorial

12 min read

How to Get All Businesses of a Type in Any Area

Build a complete list of every business of a type across a whole city or country from Google Maps. Two reliable approaches give you full coverage: tile the area with bounding boxes to reach anywhere in the world, or loop over zip codes for a fast run in the US.

May 8, 2026By Adam Benayoun

Google Maps · Local Business Data · Node.js · Python

Key Takeaways

  • Google Maps returns up to 500 results per query (often fewer, sometimes around 200) and biases them toward the center, so one large query over a city misses most businesses.

  • The bounding box endpoint is the most robust path to full coverage, and it works anywhere in the world - no postal codes required.

  • Tile the area and let density set the tile size: split any tile that returns more than ~100 results (Google may cap a response well below the true count), and skip tiles that come back empty.

  • In the US, querying by zip code (query: pizzeria in "10001" usa) is a quicker alternative that needs no coordinates.

  • Always deduplicate by business_id - overlapping tiles and adjacent zip codes return the same business more than once.

  • Add extract_emails_and_contacts=true to pull emails, phone numbers, and social profiles inline - no separate contact API needed.

Why a single query misses businesses

When you search Google Maps for "pizza in New York", results come back ranked by relevance and proximity to the center of the map viewport. If there are 2,000 pizza places in the city, you get at most 500 - the ones closest to the search anchor point.

Businesses in outer neighborhoods, boroughs, and the edges of your query area get dropped. For market research, lead generation, or competitive analysis, that missing 70-80% is the data you actually need.

The fix is the same in both methods below: break the area into smaller units, query each one, and deduplicate. They differ in how you define those units.

Bounding box (recommended)

Works anywhere. Define a rectangle by two corner coordinates, then tile a region into boxes sized to local density. Up to 500 results per call. Endpoint: /area-search-by-bounding-box.

Zip code queries (US)

Quicker to set up in the US. One call per zip code, no coordinates needed. ~100-300 results per zip. Uses the standard /search endpoint with the zip in the query string.

Approach 1: Bounding box (recommended, works anywhere)

The /area-search-by-bounding-box endpoint on the Local Business Data API searches inside a rectangle defined by two corners: bottom_left (southwest) and top_right (northeast). No postal codes, so it works in every country.

Start with one box over your target area. The example below covers Brooklyn, NY.

Node.js

// get-pizzerias-brooklyn.mjs  (Node 18+, native fetch)
const API_KEY = process.env.OWN_API_KEY;

const params = new URLSearchParams({
  query: 'pizza',
  bottom_left: '40.606549,-74.013892',  // SW corner
  top_right:   '40.691987,-73.904029',  // NE corner
  limit: '500',
  region: 'us'
});

const resp = await fetch(
  'https://api.openwebninja.com/local-business-data/area-search-by-bounding-box?' + params,
  { headers: { 'x-api-key': API_KEY } }
);

const { data = [] } = await resp.json();
console.log('Found ' + data.length + ' pizza places in this box');

Python

# get_pizzerias_brooklyn.py
import requests

resp = requests.get(
    "https://api.openwebninja.com/local-business-data/area-search-by-bounding-box",
    params={
        "query": "pizza",
        "bottom_left": "40.606549,-74.013892",  # SW corner
        "top_right": "40.691987,-73.904029",    # NE corner
        "limit": 500,
        "region": "us",
    },
    headers={"x-api-key": "YOUR_API_KEY"},
)
data = resp.json().get("data", [])
print(f"Found {len(data)} pizza places in this box")

Finding bounding box coordinates

Open Google Maps, navigate to your area, and read the lat/lng from the URL. The SW corner (bottom_left) is the lower-left of your viewport; the NE corner (top_right) is the upper-right. Tools like bboxfinder.com let you draw a rectangle and copy both corners directly.

Tile by density, not by uniform grid

One box returns at most 500 results, and Google often returns fewer than truly exist. A box over all of Brooklyn misses thousands of businesses. The answer is to split the area into a grid of smaller boxes and query each tile.

The mistake is to use one uniform grid everywhere. Business density is wildly uneven: a few blocks of downtown hold more restaurants than a hundred square miles of farmland. A fixed grid either wastes calls on empty tiles or leaves dense tiles capped at 500.

Let the data set the tile size instead. Google often returns fewer results than truly exist, sometimes as few as ~200, so do not wait for the full 500 to act. If a tile comes back with more than ~100 businesses, treat it as dense, split it into four, and query each quarter. If a tile comes back near empty, it is open country or water, so leave it alone. You only subdivide where the businesses actually are.

A Google Maps roadmap tile covering one bounded section of the map
Each tile is one bounded query. You cover a whole city by stitching tiles together, sized to how busy each area is.

Python (adaptive tiling)

# tile_area.py  --  adaptive bounding-box sweep
import requests

API_KEY = "YOUR_API_KEY"
SPLIT_AT = 100  # if a tile returns more than this, split it for better coverage

def search_box(query, sw, ne):
    r = requests.get(
        "https://api.openwebninja.com/local-business-data/area-search-by-bounding-box",
        params={
            "query": query,
            "bottom_left": f"{sw[0]},{sw[1]}",
            "top_right": f"{ne[0]},{ne[1]}",
            "limit": 500,
            "region": "us",
        },
        headers={"x-api-key": API_KEY},
    )
    return r.json().get("data", [])

def collect(query, sw, ne, seen, out, depth=0):
    rows = search_box(query, sw, ne)

    # Dense tile -> a high count means Google is probably capping the response
    # below the true total, so split into four quadrants and recurse for coverage.
    if len(rows) > SPLIT_AT and depth < 4:
        mid_lat = (sw[0] + ne[0]) / 2
        mid_lng = (sw[1] + ne[1]) / 2
        quadrants = [
            (sw,                    (mid_lat, mid_lng)),
            ((sw[0], mid_lng),      (mid_lat, ne[1])),
            ((mid_lat, sw[1]),      (ne[0], mid_lng)),
            ((mid_lat, mid_lng),    ne),
        ]
        for q_sw, q_ne in quadrants:
            collect(query, q_sw, q_ne, seen, out, depth + 1)
        return

    # Sparse tile -> we have the full picture here. Keep new businesses, dedupe by id.
    for biz in rows:
        if biz["business_id"] not in seen:
            seen.add(biz["business_id"])
            out.append(biz)

seen, out = set(), []
collect("pizza", (40.606549, -74.013892), (40.691987, -73.904029), seen, out)
print(f"Found {len(out)} unique pizza places")

The depth guard stops runaway recursion in the densest cores, where even a small tile stays above the threshold. Lower SPLIT_AT for higher coverage, raise it for fewer calls. Deduplication by business_id is what makes overlapping tiles safe - the same business returned by two adjacent tiles is counted once.

Approach 2: Zip code queries (US shortcut)

If your target is in the US, you can skip coordinates entirely. Query the standard /search endpoint once per zip code using the format pizzeria in "10001" usa. Each zip covers a small enough area that few businesses get dropped.

It is a GET request - the zip code goes inside the query string. Loop over your zip list, collect, and deduplicate by business_id.

Node.js

// get-pizzerias-by-zip.mjs  (Node 18+, native fetch)
const API_KEY = process.env.OWN_API_KEY;

// Your zip list for the target area -- NYC has ~300
const ZIP_CODES = ['10001', '10002', '10003', '10004', '10005'];

const seen = new Set();
const results = [];

for (const zip of ZIP_CODES) {
  const params = new URLSearchParams({
    query: 'pizzeria in "' + zip + '" usa',
    limit: '500',
    region: 'us'
  });

  const resp = await fetch(
    'https://api.openwebninja.com/local-business-data/search?' + params,
    { headers: { 'x-api-key': API_KEY } }
  );

  const { data = [] } = await resp.json();
  for (const biz of data) {
    if (!seen.has(biz.business_id)) {
      seen.add(biz.business_id);
      results.push(biz);
    }
  }

  await new Promise(r => setTimeout(r, 200)); // pace requests
}

console.log('Found ' + results.length + ' unique pizzerias');

Python

# get_pizzerias_by_zip.py
import requests, time

API_KEY = "YOUR_API_KEY"

# Your zip list for the target area -- NYC has ~300
ZIP_CODES = ["10001", "10002", "10003", "10004", "10005"]

seen, results = set(), []

for zip_code in ZIP_CODES:
    resp = requests.get(
        "https://api.openwebninja.com/local-business-data/search",
        params={
            "query": f'pizzeria in "{zip_code}" usa',
            "limit": 500,
            "region": "us",
        },
        headers={"x-api-key": API_KEY},
    )
    for biz in resp.json().get("data", []):
        if biz["business_id"] not in seen:
            seen.add(biz["business_id"])
            results.append(biz)
    time.sleep(0.2)

print(f"Found {len(results)} unique pizzerias")

Response (truncated)

A real result from the query above, trimmed to the main fields (each business returns 40+ in total). Every Local Business Data endpoint returns this same shape:

{
  "status": "OK",
  "request_id": "6b36b23b-43eb-4014-bfce-faba856bcac2",
  "data": [
    {
      "business_id": "0x89c25941e2fda08d:0xfecb69a95e026dfb",
      "name": "NY Pizza Suprema",
      "full_address": "NY Pizza Suprema, 413 8th Ave, New York, NY 10001",
      "phone_number": "+12125948939",
      "website": "https://www.nypizzasuprema.com",
      "rating": 4.6,
      "review_count": 8592,
      "type": "Pizza restaurant",
      "subtypes": ["Pizza restaurant", "Italian restaurant"],
      "business_status": "OPEN",
      "latitude": 40.7501778,
      "longitude": -73.9952738,
      "verified": true,
      "zipcode": "10001",
      "...": "30+ more fields per business (working_hours, about, photos_sample, reviews_per_rating, tld)"
    }
  ]
}

When zip codes fall short

Zip queries assume each zip is small enough that one call captures it. Because Google can return fewer results than truly exist, a dense zip can quietly drop some. If a zip looks under-covered, fall back to a bounding box over it and let the tiling above subdivide it. Outside the US, where postal areas are large or not queryable, use the bounding box approach from the start.

Non-rectangular coverage

Two more area endpoints cover shapes a rectangle does not fit. /area-search-by-radius takes a center point plus a radius in meters, which suits "everything within 2 km of this point". /search-in-area takes a center point plus a zoom level. Both accept the same query and return the same business shape, so the subdivide-and-deduplicate logic above applies to them with slight adjustments.

Adding emails and contacts

Add extract_emails_and_contacts=true to any of these endpoints. The API then scrapes each business's website and appends an emails_and_contacts object with emails, phone numbers, and social profile links. No separate contact scraper needed.

Add it to the request params

params={
    "query": "pizza",
    "bottom_left": "40.606549,-74.013892",
    "top_right": "40.691987,-73.904029",
    "limit": 500,
    "region": "us",
    "extract_emails_and_contacts": "true",
}

Contact fields returned per business

"emails_and_contacts": {
  "emails": ["hello@altairnyc.com"],
  "phone_numbers": ["9295616131", "2129334495"],
  "facebook": "https://www.facebook.com/AltairNY",
  "instagram": null,
  "yelp": "https://www.yelp.com/biz/altair-nyc-new-york-2",
  "tiktok": null,
  "snapchat": null,
  "twitter": null,
  "linkedin": null,
  "github": null,
  "youtube": null,
  "pinterest": null
}

Let the agent skill tile for you

If you use Claude Code with the OpenWeb Ninja skill, both methods are built in. The --grid mode tiles a bounding box and auto-subdivides dense cells exactly as described above; the --zips mode loops a list of zip codes.

# Grid mode -- tile a bounding box, auto-subdivide dense cells (recommended)
node --env-file=.env apis/local-business-data/scrape.js \
  --query "Pizzeria" \
  --grid --bbox "40.606549,-74.013892,40.691987,-73.904029" \
  --contacts --format csv

# Zip mode -- loop over a list of US zip codes
node --env-file=.env apis/local-business-data/scrape.js \
  --query "Pizzeria" \
  --zips "10001,10002,10003,10004,10005" \
  --contacts --format csv

Both modes deduplicate by business_id automatically. The --contacts flag sets extract_emails_and_contacts=true, and --bbox takes the corners as swLat,swLng,neLat,neLng.

Which approach to use

Bounding boxZip codes
Best forAnywhere in the worldThe US, when you want a fast setup
Setup neededTwo corner coordinatesA list of zip codes
Dense areasSubdivide any tile over ~100 resultsA dense zip can still drop some
DeduplicationRequired across tilesRequired across zips

FAQ

Most common questions and answers

How many results does a Google Maps business search return per query?

Why do I get duplicate businesses across calls?

How do I choose the right tile size for a bounding box sweep?

Does one bounding box call return every business in the area?

Do zip code queries work outside the US?

Start building your business dataset

The Local Business Data API returns name, address, phone, website, rating, reviews, and 40+ attributes per business across Google Maps. The free plan needs no credit card.