Your nearest-store widget needs three APIs, not one

Build a production store locator with Places, trade area boundaries, and drive-time isochrones. REST examples in curl, Python, and Node. No SDK required.

| June 05, 2026
Your nearest-store widget needs three APIs, not one

Every retail engineering team builds the nearest-store widget the same way: geocode the user's postcode, compute straight-line distances to every store in the database, return the top five. Costs half a sprint. Ships on time. Works fine in a demo.

Then someone in Manchester gets routed to a store that is technically 1.8 km away but requires crossing a motorway interchange, a railway embankment, and a retail park access road — a 22-minute drive for something that looks like a 4-minute trip. Someone in Chicago gets sent to a city-centre branch when there is a suburban location with free parking 800 metres further by crow-fly distance but four minutes closer by actual road. The complaints go to the store ops team, who forward them to product, who ask engineering for "a fix."

The fix is not a tweak to the distance sort. The fix is a different data model: nearest by travel time, not nearest by radius. Implementing that correctly requires three API calls in coordination — Places (find the stores), Boundaries (define the trade area), and Isochrones (compute the drive-time polygon). This post builds the production version, end to end, in REST.

Why straight-line distance breaks

Euclidean distance has three failure modes in a retail context, and all three appear within the first few weeks after launch on any non-trivial store network.

Barriers. Rivers, motorways, rail lines, and industrial estates create hard asymmetries between air distance and road distance. A store on the far bank of a river may be 600 m by crow-fly but 4 km by road if the nearest bridge is two miles downstream. In dense urban grids this matters less; in suburban and exurban retail — where most big-box and grocery footprints actually live — it matters constantly.

Competing stores. Raw nearest-by-distance assumes each store has a circular catchment. In reality, catchment areas are shaped by what is on the road between the user and the store. If you have two stores and the user sits equidistant between them, straight-line distance says "either one, coin flip." Drive-time isochrones may show that one store is reachable in 6 minutes because it sits at a junction, and the other in 18 minutes because the road layout forces a detour. The isochrone tells you the real trade area boundary; the circle does not.

Trade area cannibalisation. When you are planning a new location or adjusting opening hours, you need to know which existing store's trade area it will cannibalise. Overlapping 5 km radius circles on a map gives one answer; overlapping 10-minute drive-time isochrones gives a materially different one. The latter is the one your property team should be using.

All three failure modes have the same fix: replace the circle with a drive-time polygon. The polygon requires an isochrone endpoint, and the isochrone result only makes sense if you have the right store coordinates to centre it on. Which means you need a Places lookup to identify the store, a Boundaries lookup to confirm the trade area definition, and an Isochrone call to produce the polygon. One API call out of three.

The three endpoints and what each does

CSV2GEO exposes 56 endpoints across geocoding, enrichment, and spatial analysis. The three relevant to a store locator are:

`GET /api/v1/places/nearby` — given a coordinate and a radius, returns a ranked list of nearby places matching a category filter. For a retail store locator you pass your own store inventory as a custom category or filter by chain name, and the endpoint returns the nearest matches with their coordinates, addresses, and optional enrichment fields. This is the discovery call: "what stores exist near this user?"

`GET /api/v1/boundaries` — given a coordinate, returns the administrative and statistical boundaries that contain it: country, region, municipality, postal code, census tract, and (where available) Nielsen-style Designated Market Area equivalents. For trade area analysis this tells you which boundaries a given store centroid falls within — useful for rolling up sales data, targeting campaigns, and understanding market share by region.

`GET /api/v1/isochrone` — given a coordinate, a mode (drive, walk, cycle, transit), and one or more time thresholds (in minutes), returns a GeoJSON polygon representing the area reachable within those times. This is the trade-area polygon: the correct shape to use when displaying a store's catchment, ranking stores by who can realistically reach them, or checking whether a proposed new site cannibalises an existing one.

Each of these is a single REST call. You can compose them independently or chain them — geocode → places → isochrone is the production pattern for the widget, and it is three HTTP calls per user query.

What the widget actually needs to do

Before writing code it is worth being precise about the contract. A production nearest-store widget must:

  1. Accept a postcode, a city name, or a device lat/lng from the front end.
  2. Geocode it to a coordinate (if not already a coordinate).
  3. Find stores within a sensible radius of that coordinate.
  4. Rank those stores by drive time, not straight-line distance.
  5. Return the ranked list with useful metadata: address, opening hours, distance in km, drive time in minutes.
  6. Optionally, return a GeoJSON polygon per store so the front end can render the trade area on a map.

Steps 1–3 are a Places query. Step 4 is an Isochrone query. Step 6 is the Isochrone polygon surfaced to the front end. Step 5 is your application code joining the two responses.

The Boundaries endpoint comes in when you need trade-area reporting rather than a user-facing widget — knowing that store #12 sits in the same postal district as store #7, or that a new site falls in a census tract where you already have 18% market penetration. We will cover that in the section on trade-area analysis after building the widget.

Building the widget — step by step

Step 1: Geocode the user's location

If the front end already has a GPS coordinate (common on mobile), skip this call. If you are accepting a free-text postcode or city, geocode it first.

curl -G "https://csv2geo.com/api/v1/geocode" \
  --data-urlencode "q=EC1A 1BB" \
  --data-urlencode "api_key=$CSV2GEO_KEY"

In Python:

import os
import requests

API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_KEY"]

def geocode(query: str) -> dict:
    r = requests.get(
        f"{API}/geocode",
        params={"q": query, "api_key": KEY},
        timeout=10,
    )
    r.raise_for_status()
    results = r.json().get("results", [])
    if not results:
        raise ValueError(f"No geocode result for: {query}")
    top = results[0]
    if top.get("confidence", 1) < 0.6:
        raise ValueError(f"Low-confidence geocode: {top['confidence']}")
    return {"lat": top["lat"], "lng": top["lng"]}

In Node:

const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_KEY;

async function geocode(query) {
  const url = `${API}/geocode?q=${encodeURIComponent(query)}&api_key=${KEY}`;
  const r = await fetch(url);
  if (!r.ok) throw new Error(`geocode http ${r.status}`);
  const body = await r.json();
  const top = body.results?.[0];
  if (!top) throw new Error(`No result for: ${query}`);
  if ((top.confidence ?? 1) < 0.6) throw new Error(`Low confidence: ${top.confidence}`);
  return { lat: top.lat, lng: top.lng };
}

The confidence threshold matters. A postcode that geocodes to the wrong town will produce a store list that is entirely wrong, and the user will never understand why. Log every geocode call with its confidence score; alert when the rolling P5 confidence drops below 0.7. The broader discussion of what confidence scores mean is in Geocoding Confidence Scores Explained.

Step 2: Find nearby stores with the Places endpoint

The Places/nearby call returns candidates. You want the stores, not everything nearby, so filter by category or pass a custom dataset of your store locations. If CSV2GEO has your store inventory indexed, use categories to filter to your brand. If not, pass all your store coordinates and rank them application-side — the Isochrone call in Step 3 will do the real ranking.

curl -G "https://csv2geo.com/api/v1/places/nearby" \
  --data-urlencode "lat=51.5185" \
  --data-urlencode "lng=-0.0985" \
  --data-urlencode "radius=10000" \
  --data-urlencode "categories=retail.supermarket" \
  --data-urlencode "limit=10" \
  --data-urlencode "api_key=$CSV2GEO_KEY"
def find_nearby_stores(lat: float, lng: float, radius_m: int = 10000, limit: int = 10) -> list:
    r = requests.get(
        f"{API}/places/nearby",
        params={
            "lat": lat,
            "lng": lng,
            "radius": radius_m,
            "categories": "retail.supermarket",
            "limit": limit,
            "api_key": KEY,
        },
        timeout=15,
    )
    r.raise_for_status()
    return r.json().get("results", [])

Each result in results carries lat, lng, name, address, and a distance_m from the query point. That distance_m is still straight-line at this stage — you are about to replace it with drive time in Step 3.

Step 3: Get drive-time isochrones for each candidate store

This is the call that makes the widget correct. For each candidate store, request a drive-time isochrone at your chosen threshold (10 minutes is a reasonable default for a grocery locator; 20–30 minutes for a destination retail category). The response is a GeoJSON polygon.

curl -G "https://csv2geo.com/api/v1/isochrone" \
  --data-urlencode "lat=51.5185" \
  --data-urlencode "lng=-0.0985" \
  --data-urlencode "mode=drive" \
  --data-urlencode "minutes=10,20" \
  --data-urlencode "api_key=$CSV2GEO_KEY"
def get_isochrone(lat: float, lng: float, minutes: list[int], mode: str = "drive") -> dict:
    r = requests.get(
        f"{API}/isochrone",
        params={
            "lat": lat,
            "lng": lng,
            "mode": mode,
            "minutes": ",".join(str(m) for m in minutes),
            "api_key": KEY,
        },
        timeout=20,
    )
    r.raise_for_status()
    return r.json()  # GeoJSON FeatureCollection
async function getIsochrone(lat, lng, minutes, mode = 'drive') {
  const params = new URLSearchParams({
    lat, lng, mode,
    minutes: minutes.join(','),
    api_key: KEY,
  });
  const r = await fetch(`${API}/isochrone?${params}`);
  if (!r.ok) throw new Error(`isochrone http ${r.status}`);
  return r.json(); // GeoJSON FeatureCollection
}

The key question the isochrone answers for ranking purposes is not "what shape is the trade area" — it is "does the user's coordinate fall inside the N-minute polygon for each store?" A store whose 10-minute isochrone contains the user is a genuine 10-minute store. A store whose 10-minute isochrone does not contain the user but whose 20-minute isochrone does is a 10–20-minute store. The ranking writes itself.

Step 4: Point-in-polygon check to rank stores by drive time

With the isochrone polygons in hand, check which polygon tier each user coordinate falls into. A tiny implementation using only the GeoJSON coordinates — no external geometry library:

def point_in_polygon(lat: float, lng: float, polygon_coords: list) -> bool:
    """Ray-casting algorithm. polygon_coords: list of [lng, lat] pairs (GeoJSON order)."""
    x, y = lng, lat
    inside = False
    n = len(polygon_coords)
    j = n - 1
    for i in range(n):
        xi, yi = polygon_coords[i]
        xj, yj = polygon_coords[j]
        if ((yi > y) != (yj > y)) and (x < (xj - xi) * (y - yi) / (yj - yi) + xi):
            inside = not inside
        j = i
    return inside

def rank_stores_by_drive_time(user_lat, user_lng, stores, isochrones_by_store_id):
    ranked = []
    for store in stores:
        iso = isochrones_by_store_id.get(store["id"])
        drive_band = "30+"
        if iso:
            for feature in iso.get("features", []):
                mins = feature["properties"]["minutes"]
                coords = feature["geometry"]["coordinates"][0]
                if point_in_polygon(user_lat, user_lng, coords):
                    drive_band = f"<{mins}"
                    break
        ranked.append({**store, "drive_band": drive_band})
    return sorted(ranked, key=lambda s: int(s["drive_band"].lstrip("<").rstrip("+")))

This is deliberately dependency-free — no Shapely, no Turf.js, no geometry package to pin. For production use with complex or multi-ring polygons you will want a proper point-in-polygon library, but the ray-casting implementation above handles the convex and mildly-concave polygons that isochrone endpoints typically return.

Step 5: Use the Boundaries endpoint for trade area reporting

The widget is done. But the store ops and commercial teams will eventually ask: "which stores' trade areas overlap, and which postal districts am I over-indexed in?" That is a Boundaries question.

curl -G "https://csv2geo.com/api/v1/boundaries" \
  --data-urlencode "lat=51.5185" \
  --data-urlencode "lng=-0.0985" \
  --data-urlencode "api_key=$CSV2GEO_KEY"
def get_boundaries(lat: float, lng: float) -> dict:
    r = requests.get(
        f"{API}/boundaries",
        params={"lat": lat, "lng": lng, "api_key": KEY},
        timeout=15,
    )
    r.raise_for_status()
    return r.json()

The response returns the hierarchy of administrative boundaries that contain the point: country, region, county or equivalent, municipality, postcode. For each of your store locations, a Boundaries call gives you the canonical administrative context. You can then group stores by postal district, roll up coverage metrics, and answer "do stores #4 and #7 share the same postcode?" without maintaining your own spatial joins.

The practical use case: run the Boundaries call against every store coordinate once when a new location opens, store the results in your warehouse, and join them to your sales and footfall data. That join powers the "coverage by region" view in your store ops dashboard without requiring a spatial database or a GIS analyst.

Caching strategy for a store locator

A store locator is one of the best-suited applications for aggressive caching because almost nothing in it changes on a per-request basis.

Store coordinates do not move. Cache the Places/nearby response for a given bounding box at the edge (CDN or Redis) indefinitely, or until you deliberately invalidate on a store opening or closure. A cache key of nearby:{lat_rounded_2dp}:{lng_rounded_2dp}:{radius_m} gives you a hit rate above 80% on any metropolitan area with regular user traffic.

Isochrone polygons per store do not change unless the road network changes significantly — which happens, but on a timescale of months, not hours. Cache each polygon at isochrone:{store_id}:{mode}:{minutes} with a TTL of 7–14 days. If your store network has 500 locations, that is 500 cache entries per mode per minute-threshold — trivially small.

User geocode results for postcodes are stable. Cache them with a TTL of 30 days. A postcode does not change its centroid.

With this caching strategy, a fully warmed store locator serving a metropolitan area makes zero live API calls for the majority of widget renders — only when a new postcode appears in the input that has never been queried before. The Caching Geocoding Results — 90% Cost Reduction post covers the implementation pattern in detail; it applies verbatim here.

Isochrone request concurrency

If a user queries your widget and you have 10 candidate stores from the Places call, you have a choice: fire 10 isochrone requests sequentially or concurrently. Sequential is simple but slow — each call adds latency to the render path. Concurrent is fast but burns through your rate limit quickly during traffic spikes.

The right answer is bounded concurrent fan-out: fire all 10 isochrone calls in parallel but cap the total inflight requests with a semaphore. In Python:

import asyncio
import httpx

async def get_isochrone_async(client, lat, lng, minutes, mode="drive"):
    r = await client.get(
        f"{API}/isochrone",
        params={"lat": lat, "lng": lng, "mode": mode,
                "minutes": ",".join(str(m) for m in minutes), "api_key": KEY},
        timeout=20,
    )
    r.raise_for_status()
    return r.json()

async def get_isochrones_concurrent(stores, minutes, concurrency=5):
    sem = asyncio.Semaphore(concurrency)
    async with httpx.AsyncClient() as client:
        async def bounded(store):
            async with sem:
                return await get_isochrone_async(
                    client, store["lat"], store["lng"], minutes
                )
        return await asyncio.gather(*[bounded(s) for s in stores])

A concurrency of 5 is a conservative starting point for the free tier (3,000 calls/day). On a paid plan the right value is higher — Concurrency Tuning for Geocoding Pipelines walks through how to find the ceiling without triggering throttling.

Trade area overlap detection

The most useful thing a multi-store retailer can do with isochrone polygons beyond the widget is detect cannibalisation risk. If stores A and B have overlapping 10-minute drive-time polygons, they are competing for the same customers. That is either fine (you want density in a high-value market) or a problem (a new site acquisition team did not check before signing the lease).

The check is a GeoJSON polygon intersection. In Python with Shapely, a one-liner:

from shapely.geometry import shape

def polygons_overlap(iso_a: dict, iso_b: dict) -> bool:
    poly_a = shape(iso_a["features"][0]["geometry"])
    poly_b = shape(iso_b["features"][0]["geometry"])
    return poly_a.intersects(poly_b)

Without Shapely, the bounding-box pre-filter eliminates most non-overlapping pairs cheaply before you need the full polygon intersection:

def bboxes_overlap(bbox_a, bbox_b) -> bool:
    # bbox: [min_lng, min_lat, max_lng, max_lat]
    return not (bbox_a[2] < bbox_b[0] or bbox_b[2] < bbox_a[0] or
                bbox_a[3] < bbox_b[1] or bbox_b[3] < bbox_a[1])

Run this across all store pairs in your network and you get a cannibalisation matrix in a few seconds. Filter to pairs where the overlap fraction exceeds a threshold (30% is a common starting point for same-chain cannibalisation concerns) and you have the list your property team actually needs.

What not to do

Three patterns that appear in every first-pass implementation and cause problems.

Do not call the Isochrone endpoint on every keystroke in the search box. Debounce to 400 ms minimum; wait for a full postcode or a deliberate submit. Isochrone calls are more expensive than geocoding calls, and firing one per character typed burns budget without improving the user experience.

Do not use drive-time isochrones as the sole input to a site-selection decision. Isochrones tell you about the road network as it exists today. They do not tell you about a planned new road junction, a competitor about to open 400 metres from your candidate site, or the demographic profile of the reachable population. Use the isochrone as one layer; layer the Boundaries data and your sales history on top.

Do not cache isochrones at the user's exact coordinate. Cache them at the store coordinate (the polygon is computed outward from the store, not inward from the user). The user's geocoded postcode centroid is the key for the "which isochrone does this user fall into?" lookup, not the key for the isochrone itself.

Cost model for a live widget

A realistic production store locator on a mid-size UK or US retail chain — 200 stores, 50,000 widget queries per day — breaks down as follows.

Each unique postcode query costs:

  • 1 geocode call (cached after first hit; UK has ~1.7M unique active postcodes, most of which you will never see)
  • 1 Places/nearby call (cached per bounding box)
  • Up to 10 Isochrone calls per Places result (cached per store, per mode, per minute threshold)

At steady state with warm caches, the live-call fraction is perhaps 15–20% of total widget renders, because most queries come from postcodes you have already seen. At 50,000 daily widget renders and a 20% live-call rate, you are running roughly 10,000 geocode equivalents per day. That sits comfortably within the free tier (3,000 calls/day) during development and early launch, and within the entry paid plan ($54/month for 100,000 calls) for a meaningful production footprint. A serious multi-market chain with 500,000 daily widget renders and a well-tuned cache sits within the 1–2 million call bracket. See the live numbers at csv2geo.com/pricing/api.

Observability

A store locator is quiet until it is wrong. Add these three signals to your monitoring from day one.

Cache hit rate on the isochrone layer. Drops below 60% mean either your TTL is too short or you have a cache key bug that is generating spurious misses.

Geocode confidence distribution. A P5 confidence below 0.6 means your input cleaning is failing — users are entering partial postcodes, city names without country context, or addresses that fall outside your coverage. Alert on it before users start getting results for the wrong city.

Isochrone timeout rate. The isochrone call is the most computationally intensive of the three. A spike in timeouts correlates with periods of high load or with edge-case coordinates (e.g. coordinates in rural areas with sparse road networks where the reachable area calculation is less cache-friendly). The pattern for handling this gracefully — fallback to straight-line distance with an explicit "drive time unavailable" label — is better than silently returning a wrong result. The Observability for Geocoding Pipelines post has the metric definitions you want.

Frequently Asked Questions

Can I use the Isochrone endpoint for walking and cycling modes, not just driving?

Yes. The mode parameter accepts drive, walk, cycle, and transit (where transit data is available). A grocery locator serving a city-centre audience with low car ownership should use walk as the primary mode. The polygon shapes differ materially — a 10-minute walking isochrone is far more sensitive to pedestrian infrastructure gaps than a 10-minute driving isochrone.

How many isochrone thresholds can I request in a single call?

You can pass multiple minute thresholds as a comma-separated list in a single call — e.g. minutes=5,10,20,30 — and the response returns a GeoJSON FeatureCollection with one feature per threshold. One API credit per call regardless of the number of thresholds, so it is more efficient to request all your thresholds in one call than to fire one call per threshold.

What happens when a store is in a remote area with sparse road data?

The isochrone engine falls back gracefully — the polygon it returns will be smaller and less irregular than a dense urban isochrone, reflecting that fewer roads are reachable within the time budget. The result is still correct; it is not padded or estimated. If the road network data is genuinely too sparse to compute a meaningful polygon, the endpoint returns an empty geometry with an explanatory property rather than silently returning a wrong shape.

Should I use the Places endpoint or maintain my own store coordinate database?

Both is the right answer. Maintain your own authoritative store coordinate database (with geocoded addresses and verified lat/lng per location) and use it as the source for isochrone calls. Use the Places/nearby endpoint for discovery of third-party points of interest — nearby competitors, transit stops, amenities — that you would not maintain yourself.

Is the API covered by a BAA for retail healthcare or pharmacy contexts?

Yes, where required. See the HIPAA-compliant geocoding post for the full discussion of the no_record mode and BAA availability. Most pure retail store locators do not require a BAA, but pharmacy chains and health-food retail with health-related personalisation may.

What is the free tier limit and is it enough to prototype the widget?

The free tier is 3,000 calls per day, no credit card required. A store locator prototype with 10 candidate stores per query and a warm cache needs roughly 12 live API calls per uncached query (1 geocode + 1 places + 10 isochrones). That is 250 unique uncached queries per day on the free tier — more than enough to build, test, and demo the widget end to end before you commit to a paid plan.

Related Articles

---

*I.A. / CSV2GEO Creator*

Ready to geocode your addresses?

Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.

Try Batch Geocoding Free →