Mapping dealership catchments and mobile service zones
Geocode your DMS customer file, draw real drive-time catchments, define mobile service eligibility zones, and route the van — one API key.
Most dealer groups think they know their catchment. They are wrong in the same direction: they assume it roughly matches the DMA boundary, the radius their marketing agency drew, or the postcode districts the brand assigned them. The actual customer file tells a different story — a story with a tail that extends 40 miles in one direction and barely reaches 8 miles in another, shaped by a motorway junction, a local competitor, and a school that happens to sit between the dealership and the rest of the suburb.
That file — the DMS export — is already sitting on someone's desktop. It takes one batch geocoding job to turn it into a real catchment map. Add two more API surfaces — isolines around the store, and a routing call for the mobile service van — and you have a complete operational picture without any GIS tooling, any licensed mapping platform, or any integrations that require a data-sharing agreement with the brand.
This post is a working guide for the engineers and technical leads at dealer groups who are building or evaluating that capability. It covers the batch geocoding job, the isoline API for catchment visualisation and mobile service eligibility, the forward geocoding check that gates "do we come to you?" on the website, and the routing optimisation call that sequences the van's daily stops. Code throughout, cost math at the end, failure modes called out by name.
Why the DMS customer file is the starting point
The DMS holds every vehicle sale and service record associated with the dealership. Each record has a customer address. That address-to-location mapping, done in aggregate, answers questions that no brand-supplied trade-area study will ever answer honestly:
- How far do customers actually drive for a routine service appointment?
- Is there a pocket 18 miles away where you have disproportionately high sales penetration — a community that is under-served by the nearest competitor?
- Where do your lapsed customers live, and is there a geographic cluster that suggests a specific outreach campaign would work?
- What proportion of your existing service book falls within a 20-minute drive of the store, and therefore within range of a mobile service offer?
None of this requires any data you do not already own. The DMS export is yours. The geocoding step is a one-time enrichment job, with a lightweight refresh cycle for new customers.
Step 1: Export and clean the customer file
Pull the DMS export as a flat CSV. Every DMS will produce something slightly different but the columns you need are consistent: a customer ID, a street address, a city, a state or county, and a postcode. The export will also include columns you do not need for this job — VIN, service advisor, RO number, labour time — which you can drop before you upload.
Clean the file before geocoding. Geocoding is not error-correction, and an API call spent on "123 Uknown St" is a credit you cannot recover. Common issues in DMS exports:
- Addresses concatenated with the suite or unit number in the wrong field. Move
APT 4Binto the address line before geocoding. - State abbreviations in the city field.
"TX 78701"as a city value breaks most parsers. - Blank postcodes for rural addresses. These geocode less accurately. Flag them separately rather than letting them silently fail.
- PO boxes. They geocode to a post office, not to a customer's home. Flag and exclude them from any drive-time analysis.
A five-minute manual inspection of 50 rows, sorted by address length, will surface 80% of the systematic issues before you pay for a single geocoding call.
Step 2: Batch geocode the customer file via the web tool
Upload the cleaned CSV to the CSV2GEO batch tool at csv2geo.com. Credits are consumed per address row — each row in your DMS export is one call regardless of how many columns it carries. The tool accepts the standard address fields, runs the geocoding job server-side, and returns a downloadable CSV with lat, lng, and confidence appended to each input row.
For a typical single-store service book of 8,000–15,000 active customers, the batch job completes in a few minutes. The free tier covers 3,000 calls per day, which handles a first pilot run against a sample. The full book runs on a paid plan; pricing starts at $54/month for 100,000 calls — the full cost breakdown is at csv2geo.com/pricing/api.
Once the geocoded CSV is back, add two filter columns before you do anything else:
import pandas as pd
df = pd.read_csv("customers_geocoded.csv")
# Drop rows where the geocoder had low confidence — these will skew your map
df["usable"] = df["confidence"] >= 0.75
# Flag PO boxes that slipped through
df["po_box"] = df["address"].str.upper().str.contains(r"\bP\.?O\.?\s*BOX\b", na=False)
clean = df[df["usable"] & ~df["po_box"]].copy()
print(f"{len(clean):,} usable rows from {len(df):,} total")The confidence threshold is a judgment call. 0.75 is a reasonable starting point for catchment analysis — you want the shape of the distribution to be honest, and a handful of poorly geocoded addresses at the edge of the map will distort it. See Reverse-Geocoding Accuracy and the Distance Meters for the full discussion on what confidence scores mean in practice.
Step 3: Draw the real catchment with isolines
Now you have a lat/lng for every customer in the service book. Plot them on a map and you will see the real catchment shape immediately. But the more operationally useful step is to draw drive-time isolines — polygons that define "everything reachable from the dealership within N minutes of driving."
The CSV2GEO isoline endpoint takes a centre point, a transport mode, and one or more time budgets, and returns a GeoJSON polygon for each.
curl -G "https://csv2geo.com/api/v1/isolines" \
--data-urlencode "lat=33.4484" \
--data-urlencode "lng=-112.0740" \
--data-urlencode "mode=drive" \
--data-urlencode "range=900,1800,2700" \
--data-urlencode "api_key=$CSV2GEO_API_KEY"The range values are in seconds: 900 = 15 minutes, 1800 = 30 minutes, 2700 = 45 minutes. The response is a GeoJSON FeatureCollection with one polygon per range value. Each polygon is the drive-time boundary — not a radius circle, but the actual road-network shape, which will bulge along motorways and compress in grid-locked urban corridors.
The same call in Python:
import os, requests
API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_API_KEY"]
def get_isolines(lat, lng, ranges_seconds=(900, 1800, 2700)):
r = requests.get(
f"{API}/isolines",
params={
"lat": lat,
"lng": lng,
"mode": "drive",
"range": ",".join(str(s) for s in ranges_seconds),
"api_key": KEY,
},
timeout=30,
)
r.raise_for_status()
return r.json() # GeoJSON FeatureCollection
zones = get_isolines(33.4484, -112.0740)And in Node:
const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_API_KEY;
async function getIsolines(lat, lng, rangesSec = [900, 1800, 2700]) {
const params = new URLSearchParams({
lat, lng,
mode: 'drive',
range: rangesSec.join(','),
api_key: KEY,
});
const r = await fetch(`${API}/isolines?${params}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json(); // GeoJSON FeatureCollection
}Once you have the GeoJSON polygons, you can do a point-in-polygon test against your customer lat/lng set to count how many customers fall in each zone. A simple implementation with shapely:
from shapely.geometry import shape, Point
features = zones["features"]
polygons = {f["properties"]["range"]: shape(f["geometry"]) for f in features}
def zone_label(lat, lng):
pt = Point(lng, lat)
for seconds in (900, 1800, 2700):
if polygons[seconds].contains(pt):
return f"{seconds // 60}min"
return "beyond_45min"
clean["zone"] = clean.apply(lambda r: zone_label(r["lat"], r["lng"]), axis=1)
print(clean["zone"].value_counts())The output is the honest catchment breakdown. For most suburban dealerships you will find something like 55–65% of the service book within 20 minutes, a long tail out to 45 minutes, and a small but real cluster beyond that tail that is worth understanding — those are either conquest customers who drove past a competitor to reach you, or they are anomalies (PO boxes, staff addresses, fleet customers with a depot address).
Step 4: Define the mobile service eligibility zone on the website
The catchment analysis is internal. The mobile service eligibility zone is customer-facing: when a customer types their address into the "book a mobile service" form on your website, the system needs to answer "yes, we come to you" or "sorry, outside our service area."
The cleanest implementation uses the 20-minute isoline as the eligibility boundary, stored server-side as a GeoJSON polygon, and a forward geocoding call on the visitor's address to get their lat/lng, then a point-in-polygon check.
The forward geocoding call:
curl -G "https://csv2geo.com/api/v1/geocode" \
--data-urlencode "q=742 Evergreen Terrace, Springfield" \
--data-urlencode "api_key=$CSV2GEO_API_KEY"Returns:
{
"results": [
{
"lat": 44.0521,
"lng": -123.0868,
"confidence": 0.91,
"formatted_address": "742 Evergreen Terrace, Springfield, OR 97477, US"
}
]
}The server-side eligibility check in Python:
from shapely.geometry import Point
import json
# Load the 20-minute isoline polygon once at startup
with open("mobile_service_zone.geojson") as f:
zone_polygon = shape(json.load(f)["features"][0]["geometry"])
def is_eligible(address_string):
r = requests.get(
f"{API}/geocode",
params={"q": address_string, "api_key": KEY},
timeout=10,
)
r.raise_for_status()
results = r.json().get("results", [])
if not results or results[0]["confidence"] < 0.7:
return None, "unresolved_address"
pt = Point(results[0]["lng"], results[0]["lat"])
return zone_polygon.contains(pt), NoneReturn None on low confidence rather than a hard no — display a "we couldn't verify that address, please call us" message rather than incorrectly turning away a customer who lives two streets from the boundary.
Cache the isoline polygon at application startup and refresh it weekly or when you change the service zone. It is a single API call to regenerate; there is no reason to fetch it per request. The geocoding call is per-customer input and is not cacheable (addresses are unique), but it is fast — well under a second on the forward-geocoding path.
For rate-limiting during high-traffic booking windows, implement a token-bucket limiter in front of the geocoding call rather than letting requests pile up at the API boundary. The pattern is documented in detail in Rate Limiting — Token Bucket vs Leaky Bucket.
Step 5: Route the van's daily stops
The eligibility zone tells the customer whether you can reach them. The routing endpoint tells the van driver in what order to visit the confirmed bookings so the total drive time is minimised.
The CSV2GEO Routing optimise endpoint takes a depot (the dealership), a list of stop coordinates, and returns a reordered list with the shortest practical sequence. The call structure:
curl -s -X POST "https://csv2geo.com/api/v1/routing/optimize" \
-H "Content-Type: application/json" \
-d '{
"api_key": "'"$CSV2GEO_API_KEY"'",
"depot": {"lat": 33.4484, "lng": -112.0740},
"stops": [
{"id": "job_001", "lat": 33.5022, "lng": -112.0898},
{"id": "job_002", "lat": 33.4612, "lng": -111.9789},
{"id": "job_003", "lat": 33.4901, "lng": -112.1123},
{"id": "job_004", "lat": 33.4233, "lng": -112.0456}
],
"mode": "drive"
}'The response returns the stops in the optimised sequence, with the estimated drive time between each pair. The van driver sees a numbered list; the dispatcher sees a total route duration they can sanity-check against the day's labour schedule.
The same call in Python for the daily scheduling job:
import requests, os, json
API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_API_KEY"]
def optimise_van_route(depot_lat, depot_lng, stops):
"""
stops: list of dicts with keys id, lat, lng
Returns the stops in optimised drive order.
"""
payload = {
"api_key": KEY,
"depot": {"lat": depot_lat, "lng": depot_lng},
"stops": stops,
"mode": "drive",
}
r = requests.post(
f"{API}/routing/optimize",
json=payload,
timeout=30,
)
r.raise_for_status()
return r.json()["ordered_stops"]
# Example: today's confirmed mobile service bookings
todays_stops = [
{"id": "RO_4421", "lat": 33.5022, "lng": -112.0898},
{"id": "RO_4422", "lat": 33.4612, "lng": -111.9789},
{"id": "RO_4423", "lat": 33.4901, "lng": -112.1123},
]
route = optimise_van_route(33.4484, -112.0740, todays_stops)
for i, stop in enumerate(route, 1):
print(f"{i}. {stop['id']} ({stop['lat']}, {stop['lng']})")For most dealer groups running one or two mobile service vans, the daily stop count is 6–14 jobs. At that scale the routing call runs in under a second and the cost is one API credit per call — the routing endpoint does not charge per stop. Run it each morning when the day's confirmed bookings are finalised, or re-run it when a booking cancels mid-morning and the stops need resequencing.
The failure mode worth planning for: a stop with a bad lat/lng (a geocoding confidence of 0.6 from the eligibility check that you let through anyway) will route the van to the wrong street. Enforce a confidence floor at the booking stage — reject addresses below 0.7 confidence and ask the customer to verify before confirming the slot.
Putting it together: the full data flow
The four steps above form a complete pipeline:
- Batch geocode the DMS customer export →
customers_with_latlong.csv - Isoline call centred on the dealership → catchment zone polygons in GeoJSON
- Point-in-polygon against customer lat/lngs → zone distribution for marketing and ops
- Forward geocode on each website visitor's address → eligibility check against the stored 20-minute zone polygon
- Routing optimise on each morning's confirmed bookings → van stop sequence for the day
Everything runs on the same API key. The only persistent artefact is the GeoJSON polygon for the mobile service zone, which you store in your application database and refresh weekly. The customer lat/lngs live in your CRM or data warehouse; they are enriched once and not re-geocoded unless the customer updates their address.
Cost and credit arithmetic
No invented numbers — here is the arithmetic from the published pricing at csv2geo.com/pricing/api:
| Task | Credits consumed | Notes | |---|---|---| | Batch geocode 10,000 customers | 10,000 | One-time enrichment; refresh only for new customers | | Isoline call (3 zones) | 1 | Stored as GeoJSON; refresh weekly | | Eligibility geocode per website visitor | 1 per check | Cannot be batched — triggered by user input | | Routing optimise | 1 per call | Covers the full day's stop list regardless of count |
A dealership with 10,000 existing customers, 50 mobile service eligibility checks per day from the website, and one routing call per working day spends:
- Initial geocoding: 10,000 credits (one-off)
- Ongoing daily: ~52 credits/day (50 eligibility checks + 1 routing + ~1 isoline refresh amortised)
At paid plan pricing, the ongoing daily spend is comfortably within the entry tier. The initial enrichment of the full customer file fits within a single month's call budget on a $54/month plan, with headroom to spare for new-customer geocoding and monthly re-runs.
The free tier (3,000 calls/day, no credit card required) handles the initial batch job for stores with fewer than 3,000 customers, plus the first few weeks of website eligibility checks — enough to run the full technical proof of concept before committing to a paid plan.
Failure modes and how to handle them
Low-confidence geocodes that pass through silently. The most common production issue. A confidence of 0.62 on a rural address looks fine in the CSV — the lat/lng is populated, no error code, no flag. But the point is 800 metres from the real address. For the catchment analysis this is a rounding error; for the routing call it means the van driver circles the wrong block. Enforce confidence >= 0.75 as a gate at every stage, and surface low-confidence addresses to a human reviewer rather than dropping them silently.
PO box addresses that geocode to a post office. Common in rural service books. The post office lat/lng is a real coordinate and the confidence score is often high — the geocoder is correct, but the location is wrong for your purpose. Strip PO box entries at the cleaning stage before they enter your customer location database.
Mobile eligibility checks during peak booking traffic. The geocoding call on the website is synchronous and on the user's critical path. A timeout or a slow response delays the booking flow. Set a 5-second client-side timeout and fail open: if the API does not respond in time, show the customer a "please call us to confirm your area" message rather than a hard rejection. Log the timeout for your SRE team — a pattern of timeouts is a concurrency configuration issue, not an API reliability issue. See Concurrency Tuning for Geocoding — Finding the Sweet Spot for the thread-pool and connection-pool settings that prevent this.
The isoline polygon going stale. Road networks change. A new bypass opens; a motorway junction closes for three months of roadworks. If you cached the isoline polygon for the mobile service zone at launch and never refreshed it, the zone boundary is now subtly wrong. Refresh weekly — it costs one credit and takes one API call. Add an assertion in your deployment pipeline that the polygon file is no less than seven days old; fail the deployment if it is.
Routing calls with duplicate coordinates. If two bookings are at the same address (e.g. a fleet customer with two vehicles) and you pass the same lat/lng twice, the routing endpoint may behave unexpectedly depending on how the underlying solver handles zero-distance stops. Deduplicate the stop list by coordinate before calling the endpoint, and merge the RO numbers into a single stop with a note to the technician.
Frequently Asked Questions
Do I need GIS software to use the isoline output? No. The endpoint returns standard GeoJSON, which is readable by any mapping library — Leaflet, deck.gl, or any vector tile renderer — and by shapely in Python for server-side point-in-polygon checks. You do not need ArcGIS, QGIS, or any specialist GIS tooling at any point in this workflow.
How many customers need to be geocoded before the catchment analysis is useful? The shape of the catchment stabilises around 500–1,000 geocoded records for a typical suburban dealership. Smaller samples produce noisy distributions. For a new dealership with fewer than 500 customers, use the isoline zones as the catchment definition rather than inferring it from the customer file — they are a better proxy for where you *should* be reaching than for where you *have* reached.
Can I draw the mobile service zone as something other than a drive-time isoline? Yes. If your operations team defines eligibility by postcode rather than drive time, you can store a set of eligible postcodes and do a postcode lookup rather than a point-in-polygon check. The drive-time isoline is the recommended approach because it reflects actual customer experience — a postcode boundary rarely aligns with a 20-minute drive.
Can I run this for a dealer group with multiple stores, each with its own service zone? Yes. Generate one isoline per store, store each polygon keyed by store ID, and check the visitor's address against the set of polygons. Return the nearest eligible store, or show a dropdown if the address falls within multiple zones. The API call count scales linearly with the number of stores.
Is the customer data sent to CSV2GEO when I run the batch job? The batch geocoding tool receives the address fields you upload — street, city, state, postcode — and returns coordinates. It does not retain customer PII beyond what is necessary to process the job. If your data-protection policy prohibits sending customer addresses to a third-party processor, pseudonymise the export before upload: assign each row a numeric ID, strip names and any fields that are not address components, and re-join the coordinates to the original data on your side using the numeric ID.
What if a customer's address is in a state with unusual address formats — rural route numbers, lot and block references? The geocoder covers 504M+ addresses across 63 countries, including rural US addressing schemes. Rural route addresses (e.g. "RR 3 Box 42") geocode to a lower confidence than structured street addresses, typically 0.55–0.70. These should be flagged for manual review in the catchment analysis rather than silently included. The eligibility check for mobile service should require a structured street address from the customer form, with a help-text prompt explaining the format — this catches the majority of edge cases before the API call is made.
Can the routing endpoint handle time windows — e.g. customers who are only available between 9am and 12pm? The routing endpoint optimises for total drive distance and sequence. Time-window constraints (customer availability slots) are handled by the scheduling layer in your booking system before the stops are passed to the routing call. Filter the confirmed bookings for the day by technician availability and customer time windows first, then pass the resulting stop list to the routing endpoint for sequence optimisation.
Related Articles
- Benchmarking geocoding APIs — honest numbers — what to measure before committing to any geocoding vendor
- Caching geocoding results — 90% cost reduction — how to cache aggressively when addresses and zone polygons rarely change
- Reverse geocoding accuracy and distance meters — understanding confidence scores and what "accurate" actually means in production
- Exponential backoff — when to retry, when to stop — the retry policy for bulk geocoding runs that prevents hammering the API on transient errors
- Concurrency tuning for geocoding — finding the sweet spot — thread-pool and connection settings for high-throughput geocoding pipelines
---
*I.A. / CSV2GEO Creator*
Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.
Try Batch Geocoding Free →