Building restaurant delivery zones with drive-time isolines

Replace hand-drawn delivery circles with real drive-time polygons. Geocode your kitchen, generate isolines, and check each order address in seconds.

| July 18, 2026
Building restaurant delivery zones with drive-time isolines

A 1.5-mile radius circle drawn on a restaurant's order-management screen is not a delivery zone. It is a placeholder left over from before the restaurant had a developer on staff. Real delivery reach is shaped by roads, one-way systems, rail crossings, motorway bridges, and the exact position of the kitchen inside the building — not by Euclidean geometry.

Ghost kitchens feel this more acutely than traditional restaurants. A dark kitchen in a light-industrial estate might sit 200 metres from a residential block and still be an eight-minute drive because of a one-way loop and a level crossing. The naive circle says "yes" to that address; the drive-time polygon says "no" and saves the driver a two-star review.

This post walks through the full engineering pattern for replacing hand-drawn delivery circles with drive-time isolines: geocode the kitchen, call the Routing API's isoline endpoint to get a real reachability polygon for a given drive time, then check each incoming order address against that polygon. The check itself is your own code — a standard point-in-polygon test. The heavy lifting is two REST calls, both billable, both cacheable, and both fast enough to live in the order-acceptance path.

Why the circle fails in production

Before the engineering, a brief look at exactly how the naive-circle approach fails. This is worth internalising because it is the argument you will make to the product team when they ask why you are spending engineering effort on something that "already works."

Over-acceptance at range. A 2-mile radius centred on a kitchen in a dense city might include addresses that are technically within the circle but require twelve minutes of driving due to traffic calming, bollards, or one-way systems. The driver accepts the order, quotes a 30-minute delivery, and arrives in 45. The customer reviews accordingly.

Under-acceptance near fast roads. A kitchen near a motorway slip road might be able to reach a business park 4 miles away in eight minutes. The naive circle rejects that address. The delivery platform keeps showing "not available in your area" to a customer who is genuinely fast to reach. That is lost revenue.

Multi-kitchen aggregation gets ugly. A ghost kitchen operator running three brands out of one site and two more brands from a second site needs to know the union and intersection of five delivery zones. Drawing circles in a UI tool and hoping they are consistent is not a plan. Programmatic isolines are.

The fix is two API calls and twenty lines of application logic. The geometry is not especially complex; the main thing standing in the way is wiring it up correctly.

The three-step architecture

The pattern has three distinct phases.

  1. Kitchen geocoding — run once per kitchen, or on demand when a new kitchen is onboarded. Takes a street address and returns a lat/lng that becomes the anchor for everything downstream.
  2. Isoline generation — run once per kitchen per time-window setting, or whenever the operator changes their delivery radius. Takes the kitchen's lat/lng plus a drive-time budget and returns a GeoJSON polygon representing every point reachable within that time.
  3. Order address check — run on every incoming order. Takes the customer's address, geocodes it to a lat/lng, and tests whether the point falls inside the cached polygon. This is a point-in-polygon test: your code, standard geometry library, zero additional API calls.

The first two phases are periodic. The third is high-frequency. The right design separates them cleanly so that your order-acceptance path never blocks on a slow Routing API call.

Step 1: Geocode the kitchen address

Start with the kitchen. A geocode call turns a street address into the lat/lng anchor for the isoline. This call happens once when the kitchen is onboarded; you store the result and never call it again for the same address unless the kitchen physically moves.

curl -G "https://csv2geo.com/api/v1/geocode" \
  --data-urlencode "q=42 Bermondsey Street London SE1 3UD" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

The response shape:

{
  "results": [
    {
      "lat": 51.4992,
      "lng": -0.0802,
      "confidence": 0.97,
      "formatted_address": "42 Bermondsey Street, London, SE1 3UD, GB"
    }
  ]
}

The confidence field matters here. A kitchen geocoded with confidence below 0.8 is worth a manual verification pass before you use the coordinate as a routing anchor — an offset of 50 metres will bias every isoline you generate from it. If confidence is below 0.7, treat it as a data-entry problem and kick it back to the operator for address correction before completing the onboarding.

In Python:

import os
import requests

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

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

Store the result in your database. This is a one-time credit spend per kitchen address.

Step 2: Generate the drive-time isoline

Now the isoline. The Routing API's isoline endpoint takes a latitude, longitude, a transport mode, and a time budget in seconds, and returns a GeoJSON polygon (or MultiPolygon for disconnected reachability areas) that represents the full extent of where you can reach within that budget.

CSV2GEO's Routing API supports five transport modes: drive, walk, bike, truck, and motorcycle. For a food-delivery van on city streets, drive is the correct mode. Some ghost kitchens use bicycle couriers for short-range orders, in which case bike produces the more accurate zone.

curl -G "https://csv2geo.com/api/v1/routing/isoline" \
  --data-urlencode "lat=51.4992" \
  --data-urlencode "lng=-0.0802" \
  --data-urlencode "mode=drive" \
  --data-urlencode "range=1200" \
  --data-urlencode "range_type=time" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

Here range=1200 is 1,200 seconds — a 20-minute drive time. Adjust to match your operational SLA. The response is GeoJSON:

{
  "type": "Feature",
  "geometry": {
    "type": "Polygon",
    "coordinates": [
      [
        [-0.0802, 51.4992],
        [-0.0710, 51.5031],
        [-0.0634, 51.5012],
        "..."
      ]
    ]
  },
  "properties": {
    "mode": "drive",
    "range": 1200,
    "range_type": "time"
  }
}

In Python, fetching and storing the polygon:

import json

def fetch_isoline(lat: float, lng: float, drive_seconds: int = 1200, mode: str = "drive") -> dict:
    r = requests.get(
        f"{API}/routing/isoline",
        params={
            "lat": lat,
            "lng": lng,
            "mode": mode,
            "range": drive_seconds,
            "range_type": "time",
            "api_key": KEY,
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json()  # GeoJSON Feature

# Fetch and persist for kitchen_id = 7
kitchen = {"lat": 51.4992, "lng": -0.0802}
zone_geojson = fetch_isoline(**kitchen, drive_seconds=1200)
# Save to database as json column, or write to file for inspection
with open("zone_kitchen_7.geojson", "w") as f:
    json.dump(zone_geojson, f)

And the same in Node:

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

async function fetchIsoline(lat, lng, driveSeconds = 1200, mode = 'drive') {
  const url = new URL(`${API}/routing/isoline`);
  url.searchParams.set('lat', lat);
  url.searchParams.set('lng', lng);
  url.searchParams.set('mode', mode);
  url.searchParams.set('range', driveSeconds);
  url.searchParams.set('range_type', 'time');
  url.searchParams.set('api_key', KEY);

  const r = await fetch(url.toString());
  if (!r.ok) throw new Error(`Isoline API error: ${r.status}`);
  return r.json(); // GeoJSON Feature
}

Caching the isoline. This polygon does not change unless the road network changes or the operator adjusts the delivery time window. Cache it aggressively — in Redis, in your database, in a flat file on the application server. A delivery operation with 10 kitchens needs at most 10 isoline calls in total. Re-generate on a weekly schedule or when an operator explicitly updates their delivery settings; do not re-call the Routing API on every order.

Step 3: Geocode each incoming order address

When an order comes in, geocode the customer's delivery address. This is a fresh call per order — you cannot cache by address alone because the order address is user-entered and varies freely.

curl -G "https://csv2geo.com/api/v1/geocode" \
  --data-urlencode "q=18 Borough Market Lane London SE1 9AF" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

In Python, embedded in an order-acceptance function:

def geocode_order_address(raw_address: str) -> dict | None:
    r = requests.get(
        f"{API}/geocode",
        params={"q": raw_address, "api_key": KEY},
        timeout=10,
    )
    if r.status_code == 200:
        results = r.json().get("results", [])
        if results and results[0]["confidence"] >= 0.7:
            return {"lat": results[0]["lat"], "lng": results[0]["lng"]}
    return None  # caller should reject or flag for manual review

This call should be fast enough to live on the order-acceptance path. If your p99 here is a concern, see P99 Latency in Geocoding — Why the Average Lies and Concurrency Tuning for Geocoding Pipelines for practical guidance on both measurement and tuning.

Step 4: Point-in-polygon check in your application code

The CSV2GEO API does not expose a point-in-polygon endpoint — and it should not need to. Once you have the customer's lat/lng and the kitchen's cached isoline GeoJSON, the zone check is standard computational geometry: is this point inside this polygon?

In Python, shapely is the pragmatic choice:

from shapely.geometry import shape, Point

def is_in_delivery_zone(customer_coords: dict, zone_geojson: dict) -> bool:
    polygon = shape(zone_geojson["geometry"])
    point = Point(customer_coords["lng"], customer_coords["lat"])
    return polygon.contains(point)

Note the argument order: Point(lng, lat) because GeoJSON uses [longitude, latitude] ordering. Getting this backwards is one of the most common bugs in this pattern and it produces silently wrong results — orders accepted for addresses in the wrong direction from the kitchen.

In Node, @turf/boolean-point-in-polygon is the standard choice:

import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
import { point } from '@turf/helpers';

function isInDeliveryZone(customerCoords, zoneGeoJSON) {
  const pt = point([customerCoords.lng, customerCoords.lat]);
  return booleanPointInPolygon(pt, zoneGeoJSON);
}

This check runs in microseconds. The only thing that can make it slow is loading the polygon from the database on every request. Load the polygon into application memory (or a fast cache) on startup and refresh it on a background job; do not deserialise GeoJSON on the order-acceptance hot path.

Step 5: Assemble the full order-acceptance flow

Bringing all four steps together into a single function that the order-management system can call:

import json
from functools import lru_cache
from shapely.geometry import shape, Point

# Assume zone_geojson is loaded from DB or Redis at startup
@lru_cache(maxsize=None)
def load_zone(kitchen_id: int) -> object:
    # In production: fetch from Redis/DB, not from file
    with open(f"zone_kitchen_{kitchen_id}.geojson") as f:
        geojson = json.load(f)
    return shape(geojson["geometry"])

def accept_order(kitchen_id: int, raw_delivery_address: str) -> dict:
    coords = geocode_order_address(raw_delivery_address)
    if coords is None:
        return {"accepted": False, "reason": "address_unresolvable"}

    zone_polygon = load_zone(kitchen_id)
    pt = Point(coords["lng"], coords["lat"])

    if zone_polygon.contains(pt):
        return {"accepted": True, "lat": coords["lat"], "lng": coords["lng"]}
    else:
        return {"accepted": False, "reason": "outside_delivery_zone"}

The only API call in this path is the geocode of the customer's address. The polygon is cached in memory. The total latency budget for the order-acceptance check is dominated by the geocode round-trip — everything else is sub-millisecond.

Handling multiple kitchens and multi-brand operations

A ghost kitchen operator typically runs several brands from one or more physical sites. The zone logic extends naturally: each kitchen has its own isoline; when an order comes in for a specific brand, the check runs against that brand's kitchen polygon only.

Where it gets interesting is when you want to show the customer the delivery boundary before they enter their address — the "show my delivery area" polygon on the order page. For that use case, pre-load the GeoJSON polygon from your database and render it as a map overlay in the browser. No API call at render time; the polygon is already yours, stored at onboarding.

For aggregation across multiple kitchens (union of zones, used in "order from any of our locations" flows), compute the union once with Shapely or Turf at zone-generation time and store the result. Do not compute geometry unions on the order-acceptance hot path.

Failure modes worth anticipating

Three patterns that trip teams up in the first week of production.

Stale polygons after operator changes. If an operator shortens their delivery radius at 18:00 on a Friday night and the polygon cache does not invalidate, orders placed at 18:01 for addresses outside the new zone will be accepted by your application code. Build a webhook or a flag in your operator settings that triggers a cache invalidation and a fresh isoline call whenever the delivery-time setting changes.

MultiPolygon responses. Some kitchen locations produce disconnected reachability areas — think a kitchen near a river crossing where addresses on the far bank are reachable but addresses at the same bird-flight distance on the near bank are blocked by an industrial estate. The Routing API returns a MultiPolygon geometry in that case. Shapely and Turf both handle MultiPolygon in contains / booleanPointInPolygon correctly, but if you have hand-written geometry code that assumes a single outer ring, it will fail silently or raise an index error. Always check geometry.type in the response before deserialising.

Order address geocoding failures. A customer who types "flat 3 the old brewery SE1" is not giving you a machine-parseable address. Your geocoder will return either no results or a low-confidence result. The correct behaviour is to reject the order with a prompt asking for a more complete address — not to accept based on a partial match and send the driver to the wrong building. Confidence below 0.7 is the practical threshold; below that, do not attempt the zone check.

Rate limits during peak. A busy Friday dinner service can see a thousand order attempts in 30 minutes. At one geocode call per order attempt, that is roughly 33 calls per minute — well within the free tier's 3,000 calls per day on a normal service, but a kitchen group running ten locations across multiple cities might hit paid tier volume during a Friday peak. Pre-verify your monthly call budget against peak traffic projections before launch. The pricing page publishes the tier brackets; at $54/month for 100,000 calls, a busy single kitchen running 500 order-acceptance checks per day burns through 15,000 calls per month — comfortably inside the entry paid tier.

Designing for operational visibility

The zone-check result should be logged as a structured event, not just a boolean. A log line that records kitchen_id, order_id, customer_lat, customer_lng, in_zone, geocode_confidence, and timestamp gives your operations team a live map of where accepted and rejected orders are landing. After two weeks of data you will see patterns: a cluster of rejected addresses consistently 400 metres outside the zone boundary on one side might reveal a misconfigured drive-time budget, or a section of road that the routing engine rates as blocked during certain hours.

See Observability for Geocoding Pipelines for the broader instrumentation approach; the same patterns apply here with the addition of in_zone as a custom metric.

Cost model for a realistic operation

A single ghost kitchen accepting 300 orders per day, operating 25 days per month:

  • Order geocoding: 300 × 25 = 7,500 geocode calls/month
  • Kitchen geocoding: 1 call on onboarding, negligible thereafter
  • Isoline generation: 1 call on onboarding, plus ~4 weekly refreshes = ~5 calls/month

Total: roughly 7,500 calls per month. That is comfortably inside the $54/month paid-tier bracket with significant headroom. A 10-kitchen operation scales linearly to ~75,000 calls per month — still within the same bracket or one above, depending on order volume.

The free tier (3,000 calls per day) covers staging, load testing, and pilot operations with room to spare.

Frequently Asked Questions

Can I use this for bicycle couriers instead of cars?

Yes. Pass mode=bike to the isoline endpoint instead of mode=drive. The resulting polygon will be shaped by cycle paths and speed assumptions appropriate for cycling rather than driving. For mixed fleets — some orders delivered by car, some by bike — generate two separate isolines and apply the check against the appropriate polygon based on the courier assignment logic in your order-management system.

How often should I refresh the isoline?

Refresh when: the operator changes their delivery time window, the kitchen relocates, or the road network in the area has a significant structural change (new one-way street, road closure becoming permanent). A weekly background refresh is a sensible default for a stable operation. Daily is overkill; monthly is too coarse if operators are actively tuning their zones.

What happens if a customer's address geocodes to a point on a boundary?

The point-in-polygon check is binary — the point is inside or it is not. If the point falls exactly on the boundary, most implementations (including Shapely's contains) return False; use covers if you want boundary points treated as inside. In practice, a customer address geocoded to a precise boundary is vanishingly rare — the more common scenario is an address near the boundary that is confidently inside or confidently outside by a meaningful margin.

Does the isoline account for real-time traffic?

The isoline is generated against the road network at the time of the call, using typical travel-time estimates. It does not incorporate live traffic data. For delivery operations, this is the right trade-off: you want a stable, cacheable polygon for the order-acceptance UI, not a polygon that reshapes every five minutes based on current congestion. Real-time traffic is a concern for ETA calculation after the order is accepted, which is a different problem.

Should I store the isoline polygon in PostGIS?

If your order database is Postgres, storing the isoline as a geometry column in PostGIS and running the point-in-polygon check as a PostGIS ST_Contains query is a clean production pattern. It gives you the zone check without a Shapely dependency in application code, and you get spatial indexing for free. The GeoJSON the API returns is directly importable via ST_GeomFromGeoJSON. For teams without PostGIS, the Shapely / Turf application-level approach described above works well for single-kitchen or small multi-kitchen deployments.

What transport mode should I use for motorcycle delivery?

Use mode=motorcycle. Motorcycles have different road-access rules from cars in many jurisdictions — they can use bus lanes in some cities, are restricted from certain motorway sections, and have different speed assumptions in urban traffic. The motorcycle isoline will generally be slightly larger than the drive isoline for the same time budget in a congested urban environment.

Can I generate multiple isolines for the same kitchen at different time windows?

Yes. Call the isoline endpoint once per time budget — for example, a 15-minute zone and a 25-minute zone for the same kitchen. Store both polygons and apply different delivery-fee tiers based on which zone the order falls in. This is a common pattern for ghost kitchens that charge a delivery premium for orders at the outer edge of their reach.

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 →