Answering 'do you serve my area?' instantly on your website

Use drive-time isolines and forward geocoding to answer 'do you serve my area?' instantly on your home-services website. REST patterns, real code.

| August 26, 2026
Answering 'do you serve my area?' instantly on your website

Every home-services website gets the same question before anything else: *do you serve my area?* A plumber in Leeds, an HVAC technician outside Austin, a window-cleaning company based in Perth — they all field this question dozens of times a day. It is the highest-intent question a local-business website receives, because only someone who actually wants to hire you bothers to ask it.

Most sites answer badly. They show a static image of a hand-drawn circle on a mapping screenshot that was uploaded in 2019. Or a list of town names in bullet points that was never updated after the business expanded. Or worst of all, a phone number with the instruction to "call us to check." Each of those non-answers burns the conversion — the visitor closes the tab and calls whoever answered the question first.

The right answer is a form field, a geocoded lookup, and a yes-or-no response that also tells the visitor when they can expect to be seen. This post shows exactly how to build it. Three REST calls, a point-in-polygon check that runs in your application code, and a UI that gives a confident answer in under a second.

Why this is a conversion problem, not a mapping problem

Frame this right from the start. You are not building a map. You are building a yes/no gate on your highest-converting page — the homepage, or the "contact us" page — that either books a lead into the funnel or tells them honestly that you cannot help them before they waste ten minutes filling in a quote form.

The economic case is simple. If your site gets 500 "quote request" submissions per month and 60% of them are outside your service area, your office staff spends roughly a third of their week calling people back to tell them no. That is three hours of staff time per day that costs money and embarrasses everyone involved. The visitor is annoyed. Your staff is apologetic. Nobody booked anything. A real-time service-area check eliminates this category of waste entirely.

There is also a positive conversion case. When a visitor types in their postcode or address and immediately sees "Yes, we cover your area — next available slot is Tuesday," that is a booking prompt. The uncertainty that made them hesitant to fill in the form is gone. They know the answer before they commit their email address. Conversion rates on lead-capture forms that answer the coverage question inline are consistently higher than the same form without the check — not because of some UX magic, but because you removed the most obvious objection at exactly the right moment.

The technical requirement to deliver this is modest. You need to define your service area as something queryable — a drive-time isoline from your shop address, or a set of postcodes, or an administrative boundary — and then test whether a visitor-entered address falls inside it. The tricky part is not the geometry; it is doing it fast enough that the check feels instant rather than like a server trip.

Defining the service area: isolines versus boundaries

Before you can answer the question, you need to know the shape of the answer. Two approaches, and they serve different kinds of businesses.

Drive-time isolines

A drive-time isoline (sometimes called an isochrone) is the set of all points reachable from your shop within a given travel time. A plumber who says "we cover everywhere within 45 minutes of our depot" is describing a drive-time isoline. This is the honest service area for any business where technician travel time is the binding constraint.

CSV2GEO's routing-isolines endpoint takes your shop's coordinates, a travel time in minutes, and a transport mode, and returns a polygon in GeoJSON. That polygon is your service area. You store it once — or regenerate it quarterly as your routing data updates — and use it for every incoming coverage check.

The isoline is more honest than a radius. A 30-mile radius from a shop on the outskirts of a city will include remote rural areas that take 90 minutes to reach, and exclude dense urban areas that are geographically further but faster to drive. A 45-minute drive-time isoline captures the business reality.

Postcode or admin-boundary areas

Some businesses define coverage by postcode district, county, or local authority — "we cover the WF, LS, and BD postcode districts" or "we cover Harris County and Fort Bend County." For these, the Boundaries and Divisions endpoint returns the polygon geometry for any named administrative unit. You query once per boundary, union the results into a single MultiPolygon, and use that for the check.

This is the right approach when your pricing, your subcontractors, or your franchise agreement is organised along administrative lines rather than travel time. It is less honest for a customer who wants to know "will you actually come to me?" but it is accurate for the cases where it applies.

For most home-services businesses, the isoline approach is better. The rest of this post uses isolines; swapping in admin-boundary polygons is a one-line change to the geometry source.

The three API calls

The full workflow involves three calls. Two are configuration-time calls you make once (or refresh periodically). One is per-visitor, on demand.

Call 1: geocode your shop address. You need your depot's lat/lng to generate the isoline. Do this once and hardcode the result; your shop is not moving.

Call 2: fetch the drive-time isoline. Pass your depot coordinates plus the travel-time threshold to the isolines endpoint. Store the resulting GeoJSON polygon in your application. Refresh it when your coverage area changes — quarterly is reasonable.

Call 3: geocode the visitor's address. When a visitor types their address or postcode, you forward-geocode it to a lat/lng, then run the point-in-polygon check client-side or server-side.

Let us walk through each.

Step 1: Geocode your shop address

This is a one-time call. You run it from the command line, note the coordinates, and put them in your application config.

curl -s "https://csv2geo.com/api/v1/geocode" \
  --data-urlencode "q=47 Maple Street, Austin TX 78701" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY" \
  -G | jq '.results[0] | {lat, lng, confidence}'

You will get back something like:

{
  "lat": 30.2672,
  "lng": -97.7431,
  "confidence": 0.97
}

Confidence above 0.9 means the geocoder is certain about this address. If yours comes back lower, review the address string — check the postcode and city name match the street. The confidence scores post covers interpreting this field in detail.

Store lat and lng as SHOP_LAT and SHOP_LNG in your environment config. Every isoline call and every distance sanity-check downstream starts from here.

Step 2: Fetch your drive-time isoline

With your shop coordinates confirmed, fetch the isoline. The routing-isolines endpoint returns a GeoJSON polygon representing your reachable area.

curl -s "https://csv2geo.com/api/v1/isolines" \
  --data-urlencode "lat=30.2672" \
  --data-urlencode "lng=-97.7431" \
  --data-urlencode "time=45" \
  --data-urlencode "mode=drive" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY" \
  -G > service_area.geojson

The resulting file is a GeoJSON Feature containing a Polygon (or MultiPolygon if the road network creates disconnected reachable areas, which happens around water bodies or mountains). Store this file in your application — in a CDN, in a database column typed as jsonb, or as a static file served alongside your front end.

In Python, if you are generating it as part of a setup script:

import os
import json
import requests

KEY = os.environ["CSV2GEO_API_KEY"]
SHOP_LAT = 30.2672
SHOP_LNG = -97.7431
TRAVEL_MINUTES = 45

r = requests.get(
    "https://csv2geo.com/api/v1/isolines",
    params={
        "lat": SHOP_LAT,
        "lng": SHOP_LNG,
        "time": TRAVEL_MINUTES,
        "mode": "drive",
        "api_key": KEY,
    },
    timeout=30,
)
r.raise_for_status()

with open("service_area.geojson", "w") as f:
    json.dump(r.json(), f)

print("Isoline saved. Polygon coordinates:", 
      len(r.json()["geometry"]["coordinates"][0]), "points")

This call costs one credit and you make it once. Refresh it when your service radius changes — after hiring a second technician, after opening a second depot, after a seasonal coverage shift.

One important note: the isoline is a *routing* polygon, not a radius. It follows real roads. The shape will be asymmetric — faster motorway routes extend the boundary in some directions, whilst a river or dense urban grid will pull it in. This is the honest shape of your coverage. Show it to yourself in QGIS or in a quick Leaflet prototype before you trust it in production.

Step 3: Build the visitor geocoding call

When a visitor enters their address or postcode, you forward-geocode it and get back a lat/lng. This is the per-visitor, on-demand call. It wants to be fast — under 300 ms end-to-end — so your form UX feels responsive.

In the browser, via a small server-side proxy that holds your API key (never expose your API key in front-end JavaScript):

// server-side route handler, e.g. Express or Next.js API route
export async function POST(req) {
  const { address } = await req.json();

  const url = new URL("https://csv2geo.com/api/v1/geocode");
  url.searchParams.set("q", address);
  url.searchParams.set("api_key", process.env.CSV2GEO_API_KEY);

  const upstream = await fetch(url.toString());
  if (!upstream.ok) {
    return Response.json({ error: "geocode_failed" }, { status: 502 });
  }
  const data = await upstream.json();
  const top = data.results?.[0];
  if (!top || top.confidence < 0.6) {
    return Response.json({ error: "low_confidence" }, { status: 422 });
  }
  return Response.json({ lat: top.lat, lng: top.lng, confidence: top.confidence });
}

The confidence gate matters. A visitor who types "Main St" without a town or postcode will get back a low-confidence geocode that could be anywhere. Returning an error prompt — "Could you include your postcode or town?" — is better than silently geocoding the wrong place and telling them they are in or out of area incorrectly.

The threshold of 0.6 is a reasonable starting floor; you may want to raise it to 0.7 or 0.8 depending on how much address ambiguity your catchment area generates. Rural addresses and partial postcodes can be genuinely ambiguous; it is better to ask for clarification than to give a wrong answer. The full rationale for choosing confidence thresholds is in the confidence scores explainer.

Step 4: Run the point-in-polygon check

You have the visitor's lat/lng. You have the isoline polygon. Now you need to know whether the point is inside the polygon. This is a standard computational geometry operation — you do not need a spatial database or a GIS library with C extensions. A pure-JavaScript or pure-Python ray-casting implementation works fine for a single polygon.

In Node (no external dependencies):

function pointInPolygon(lat, lng, polygonCoords) {
  // polygonCoords is GeoJSON coordinate array: [ [lng, lat], ... ]
  let inside = false;
  const x = lng, y = lat;
  for (let i = 0, j = polygonCoords.length - 1; i < polygonCoords.length; j = i++) {
    const xi = polygonCoords[i][0], yi = polygonCoords[i][1];
    const xj = polygonCoords[j][0], yj = polygonCoords[j][1];
    const intersect = ((yi > y) !== (yj > y)) &&
      (x < (xj - xi) * (y - yi) / (yj - yi) + xi);
    if (intersect) inside = !inside;
  }
  return inside;
}

// Usage — serviceArea is the GeoJSON Feature from Step 2
const coords = serviceArea.geometry.coordinates[0];
const covered = pointInPolygon(visitorLat, visitorLng, coords);

For a MultiPolygon (disconnected service area), iterate over each sub-polygon and return true if the point is in any of them.

In Python, the same logic:

def point_in_polygon(lat, lng, polygon_coords):
    """Ray-casting algorithm. polygon_coords is list of [lng, lat] pairs."""
    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

# coords from service_area.geojson
with open("service_area.geojson") as f:
    service_area = json.load(f)

coords = service_area["geometry"]["coordinates"][0]
covered = point_in_polygon(visitor_lat, visitor_lng, coords)

This runs in microseconds. No network call, no database query — it is pure arithmetic on a list of coordinates that you loaded once at startup. The polygon has perhaps a few hundred vertices; the calculation is negligible compared to the geocoding round-trip.

Step 5: Respond to the visitor

The full check — geocode the visitor's input, run the polygon test — completes in one network round-trip to the geocoding API plus a negligible computation. The response your form shows to the visitor should do three things:

  1. Answer the yes/no clearly and immediately. "Yes, we cover your area" or "Sorry, your postcode (CB6 2BL) is outside our service area."
  2. If yes, give next steps. "We typically book jobs within 2–3 working days — fill in the form below to request a slot." This converts the check into a booking action.
  3. If no, do not just abandon them. "We do not currently cover your area, but we are expanding — leave your email and we will let you know." This saves the lead even when the answer is no, and it gives you data on demand density outside your current boundary.

Here is a minimal end-to-end handler that stitches calls 3 and 4 together into a single /api/check-coverage POST:

// POST /api/check-coverage
// body: { "address": "14 Acacia Road, Norwich NR1 1AA" }

import serviceArea from "../data/service_area.json" assert { type: "json" };

function pointInPolygon(lat, lng, coords) {
  let inside = false;
  for (let i = 0, j = coords.length - 1; i < coords.length; j = i++) {
    const [xi, yi] = coords[i], [xj, yj] = coords[j];
    if (((yi > lat) !== (yj > lat)) &&
        (lng < (xj - xi) * (lat - yi) / (yj - yi) + xi)) {
      inside = !inside;
    }
  }
  return inside;
}

export async function POST(req) {
  const { address } = await req.json();
  if (!address || address.trim().length < 4) {
    return Response.json({ error: "address_too_short" }, { status: 400 });
  }

  // Geocode
  const geoUrl = new URL("https://csv2geo.com/api/v1/geocode");
  geoUrl.searchParams.set("q", address);
  geoUrl.searchParams.set("api_key", process.env.CSV2GEO_API_KEY);

  const geoRes = await fetch(geoUrl.toString());
  if (!geoRes.ok) return Response.json({ error: "upstream_error" }, { status: 502 });

  const geoData = await geoRes.json();
  const hit = geoData.results?.[0];
  if (!hit || hit.confidence < 0.65) {
    return Response.json({
      covered: null,
      message: "We could not locate that address precisely — could you include your postcode?"
    }, { status: 200 });
  }

  // Point-in-polygon
  const geomType = serviceArea.geometry.type;
  let covered = false;
  if (geomType === "Polygon") {
    covered = pointInPolygon(hit.lat, hit.lng, serviceArea.geometry.coordinates[0]);
  } else if (geomType === "MultiPolygon") {
    covered = serviceArea.geometry.coordinates.some(poly =>
      pointInPolygon(hit.lat, hit.lng, poly[0])
    );
  }

  return Response.json({
    covered,
    confidence: hit.confidence,
    message: covered
      ? "Yes — we cover your area. We typically book within 2–3 working days."
      : "Sorry, your address is currently outside our service area."
  });
}

This endpoint is your complete coverage checker. It is fewer than 60 lines, has no external GIS dependencies, and does not store any visitor data beyond the duration of the request. Point your form's submit handler at it, render the message field in the UI, and you are done.

Caching the geocode result

The geocode call is the only variable-cost part of this workflow. The isoline polygon is fetched once and stored. The point-in-polygon check is free. The geocoding call costs one credit per visitor query.

On a typical SMB home-services site — perhaps 200 coverage checks per day during peak season — the free tier of 3,000 calls per day covers this comfortably with room to spare. If your site is busier than that, caching the geocode result by normalised input string cuts costs substantially. A visitor who types "SW1A 1AA" and another who types "sw1a1aa" are the same query; normalise before you cache.

The caching post covers the full caching strategy. For this specific use case, a 24-hour TTL on normalised address strings is sensible — address geocodes do not change daily, and the visitor will not notice a one-day-old cached result.

import hashlib, json
from functools import lru_cache

def normalise_address(raw: str) -> str:
    return raw.strip().lower().replace(",", "").replace("  ", " ")

# A simple in-process cache for illustration; use Redis in production.
_geocode_cache: dict[str, dict] = {}

def geocode_with_cache(address: str, api_key: str) -> dict | None:
    key = hashlib.md5(normalise_address(address).encode()).hexdigest()
    if key in _geocode_cache:
        return _geocode_cache[key]
    r = requests.get(
        "https://csv2geo.com/api/v1/geocode",
        params={"q": address, "api_key": api_key},
        timeout=10,
    )
    r.raise_for_status()
    hit = r.json().get("results", [None])[0]
    if hit:
        _geocode_cache[key] = hit
    return hit

Per-visitor API keys and rate isolation

CSV2GEO supports per-user API keys issued from the /api-keys dashboard. For a single-business website checking its own coverage, you will use one key shared across all server-side requests. That key sits in an environment variable on your server, never in client-side code.

If you are building a multi-location franchise scenario — several branches each with their own service area, all running the same codebase — you can issue a separate API key per branch. This gives you per-branch usage accounting and lets you set independent rate limits so one busy branch cannot starve another's quota. Each branch stores its own service_area.geojson and its own key.

Error handling and graceful degradation

A coverage check that fails should not block a booking. If the geocoding call returns a network error or a 5xx from the API, the right behaviour is to fall back gracefully: show the user a form that skips the coverage check and captures the lead anyway. You can do the coverage verification offline against the submitted address after the fact.

The exponential backoff post covers the retry logic in detail. For a synchronous user-facing call, the rule is: one retry after 500 ms, then fail open rather than blocking the user indefinitely. A timeout of 3 seconds total is appropriate — if the geocode has not returned in 3 seconds, the page is already feeling slow, and you should show the form rather than spinning.

import time

def geocode_with_retry(address, api_key, max_attempts=2):
    for attempt in range(max_attempts):
        try:
            r = requests.get(
                "https://csv2geo.com/api/v1/geocode",
                params={"q": address, "api_key": api_key},
                timeout=2.5,
            )
            if r.status_code < 500:
                return r.json().get("results", [None])[0]
            # 5xx: retry with backoff
            if attempt < max_attempts - 1:
                time.sleep(0.5 * (2 ** attempt))
        except requests.Timeout:
            if attempt < max_attempts - 1:
                time.sleep(0.5)
    return None  # Caller falls back to unchecked form

What this is not

Honest constraints, because leaving them unstated causes problems later.

This does not give you real-time traffic-aware routing. The isoline is generated from a routing graph that reflects typical travel times, not live traffic. Rush hour can double travel times on urban routes. If you are using 45-minute isolines and promising arrivals within 45 minutes, build in buffer. The isoline defines where you *can* serve, not whether Tuesday afternoon in school pickup hour is going to work.

The polygon is as accurate as the routing data. Routing data is updated periodically, not in real time. A new road might not be reflected immediately. For most home-services businesses this is irrelevant — you are not drawing fine boundaries — but do not quote the isoline edge to within half a mile as a contractual boundary.

This post is about a single business's website. If you are building a marketplace where multiple businesses each have service areas and you need to match visitor queries to the right supplier, that is a different architecture. The marketplace-side coverage problem is a separate engineering challenge.

Frequently Asked Questions

Can I define my service area as a list of postcodes instead of an isoline? Yes. Use the Boundaries endpoint to retrieve the polygon geometry for each postcode district, union them in your application code, and use the resulting MultiPolygon for the point-in-polygon check. The visitor geocoding and the check logic are identical — only the polygon source changes.

What if a visitor's address is right on the isoline edge? It will get classified as inside or outside based on the geometry, same as any other point. If you have buffer concerns — "we will go a little outside our formal area for the right job" — add a second, slightly larger polygon as a "maybe" zone and return a different message: "You are just at the edge of our area — call us to confirm."

How often should I regenerate the isoline? When your coverage policy changes — new depot, new technician, seasonal expansion. There is no point refreshing it daily; the routing data it is based on does not change that frequently. Quarterly is a reasonable default for an active business.

Do I need to store visitor addresses? No, and for GDPR and CCPA compliance you probably should not unless you have a clear retention justification. The geocode result is ephemeral — you get the lat/lng, run the check, return a yes/no, and discard the coordinates. You never need to log which address was queried for the coverage check to work.

Does the free tier cover a typical SMB website? Almost certainly. The free tier provides 3,000 calls per day. If your homepage gets 200 unique visitors per day and half of them run a coverage check, that is 100 geocoding calls — 3% of your free allowance. You would need a high-traffic campaign or a very popular service area before you approach the free-tier ceiling. Paid plans start at $54/month for 100,000 calls, which covers roughly 3,000 coverage checks per day.

What happens if the visitor types a non-existent address? The geocoder returns either no results or a low-confidence result. Your confidence gate (0.6–0.7 threshold) catches this and returns a prompt asking for more detail. The visitor sees "We could not locate that address — could you add your postcode?" rather than a spurious yes or no.

Can I use this for a multi-location business with different service areas per branch? Yes. Generate a separate isoline per branch depot and store them separately. Your coverage check routes the visitor's query to the nearest branch first, or tests against each branch's polygon and returns the closest one that covers the address. Issue per-branch API keys from the /api-keys dashboard if you want per-branch usage tracking.

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 →