Defining pro coverage areas in a home services marketplace
Replace radius circles with drive-time isolines for every pro in your marketplace. Forward-geocode job requests, match against real polygons.
Radius circles lie. Every home-services marketplace eventually builds them — they are simple, they store as a single float, and they render in three lines of front-end code. They are also wrong for a large enough fraction of jobs that pros churn over them.
The problem is not the concept of a coverage area; it is the geometry. A 15 km circle centred on a plumber's base address in the north of a dense city sweeps in a job that is technically 12 km away but takes 55 minutes to drive because a motorway interchange and a river crossing sit between them. The pro accepts the job, arrives late, gets a bad review, and adjusts their radius downward until they are leaving money on the table elsewhere. Repeat until the pro leaves the platform.
The honest version of a coverage area is a drive-time isoline: the boundary of everywhere the pro can reach within — say — 30 minutes of driving from their base. That boundary has a very different shape from a circle in any real city. It reaches further along motorway corridors and contracts sharply where a river, a park, or a freight terminal interrupts the road network. It is the geometry the pro actually carries in their head. When you display it back to them and to job-seekers browsing your platform, both sides trust it.
This post walks through exactly how to build that, end to end, using CSV2GEO's Isolines and Boundaries surfaces, with forward geocoding of every incoming job request. The matching logic that turns a geocoded point into a pro assignment is your own code — I will show you the shape of it, not the full implementation. That trade-off is intentional: every marketplace's ranking rules are its moat.
Why this is different from dispatch
The dispatch console post covers a single-company fleet: one scheduler, one set of vehicles, one set of jobs per day. Coverage areas in a two-sided marketplace are different in three ways.
First, the matching is opt-in. A pro sets their drive-time budget and their base address. The platform computes the isoline from those inputs. The pro can adjust either at any time. You do not assign jobs to pros — you surface jobs within their declared coverage area and let them accept.
Second, the display is public and persistent. A plumber's coverage polygon appears on their profile page, on area-browse pages ("Plumbers in Southwark"), and in your SEO content for neighbourhood landing pages. The shape has to be honest and renderable, not just a runtime computation buried in dispatch logic.
Third, the coverage areas overlap. Multiple pros can cover the same job. Your ranking logic — a mix of drive time, rating, response rate, and price — decides who appears first. The geometry tells you who is eligible; your business rules tell you who is ranked.
These three differences mean the architecture is: build polygons once per pro, update them when the pro moves or changes their drive-time setting, index them for point-in-polygon queries, and geocode every incoming job request to get the point you need to query against.
The data model
Before the API calls, a sketch of what you are building.
pros
id, name, base_address, base_lat, base_lng, drive_time_minutes,
coverage_polygon_geojson, coverage_updated_at
job_requests
id, description, address, lat, lng, geocoded_at, status
pro_coverage_matches
job_id, pro_id, drive_time_to_job_minutes, rankThe coverage_polygon_geojson column holds the GeoJSON polygon returned by the Isolines endpoint. It is recomputed whenever base_address or drive_time_minutes changes. The job_requests table has a lat, lng pair that comes from a single forward-geocode call on the job's address field. The pro_coverage_matches table is populated by your point-in-polygon query — which pros' polygons contain the job's point — followed by your ranking step.
None of this requires a GIS engine. PostGIS handles the point-in-polygon query in one SQL function. For smaller scale a library like Turf.js (Node) or Shapely (Python) does the same in application code.
Step 1: Geocode the pro's base address
Before you can request an isoline you need a coordinate. Pros enter their base address as a free-text field. You forward-geocode it once on save and store the lat/lng.
curl -s "https://csv2geo.com/api/v1/geocode" \
-G \
--data-urlencode "q=47 Maple Street, Bristol, BS1 1AA" \
--data-urlencode "api_key=$CSV2GEO_KEY"import requests, os
API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_KEY"]
def geocode_address(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 geocoding result for: {address}")
top = results[0]
if top.get("confidence", 0) < 0.7:
raise ValueError(f"Low confidence ({top['confidence']}) for: {address}")
return {"lat": top["lat"], "lng": top["lng"], "confidence": top["confidence"]}The confidence gate matters here. A low-confidence geocode produces a base coordinate that is wrong — the isoline is built from the wrong origin and the pro's coverage area silently covers the wrong neighbourhood. Reject low-confidence geocodes and surface the error to the pro at save time rather than storing a bad polygon that causes invisible mis-matches for months. The full confidence score taxonomy is covered in geocoding confidence scores explained.
Step 2: Request the drive-time isoline
With base_lat and base_lng confirmed, call the Isolines endpoint to get the coverage polygon. The request specifies the origin point and the drive-time budget in minutes. The response is a GeoJSON polygon.
curl -s "https://csv2geo.com/api/v1/isoline" \
-G \
--data-urlencode "lat=51.4545" \
--data-urlencode "lng=-2.5879" \
--data-urlencode "travel_time=30" \
--data-urlencode "mode=drive" \
--data-urlencode "api_key=$CSV2GEO_KEY"def fetch_isoline(lat: float, lng: float, drive_time_minutes: int) -> dict:
r = requests.get(
f"{API}/isoline",
params={
"lat": lat,
"lng": lng,
"travel_time": drive_time_minutes,
"mode": "drive",
"api_key": KEY,
},
timeout=30,
)
r.raise_for_status()
return r.json()["isoline"] # GeoJSON Polygon or MultiPolygonAnd the same in Node:
const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_KEY;
async function fetchIsoline(lat, lng, driveTimeMinutes) {
const params = new URLSearchParams({
lat, lng,
travel_time: driveTimeMinutes,
mode: 'drive',
api_key: KEY,
});
const r = await fetch(`${API}/isoline?${params}`);
if (!r.ok) throw new Error(`isoline ${r.status}`);
const body = await r.json();
return body.isoline; // GeoJSON Polygon or MultiPolygon
}Store the returned GeoJSON verbatim in coverage_polygon_geojson. Log the updated_at timestamp. The polygon stays valid until the pro changes their base address or their drive-time setting — you do not need to refresh it on a schedule.
Two things to build explicitly:
Trigger recomputation on change. When a pro saves a new base address or adjusts their drive-time budget, re-run Steps 1 and 2 and overwrite the stored polygon. The old polygon is wrong the moment they move base; do not leave it displayed.
Soft-fail gracefully. If the Isolines endpoint returns a 5xx during the save flow, do not block the pro's save. Store the address change, set coverage_polygon_geojson = NULL, and enqueue a background job to retry isoline computation. A pro with a NULL polygon does not appear in browse results until the polygon is computed, which is better than either blocking the save or displaying a stale polygon.
Step 3: Backfill coverage polygons for your existing pro roster
If you have an existing pro roster with base addresses but no isolines, the WEB batch tool lets you upload the full address list and geocode it in one pass. Credits are consumed per address row — a roster of 10,000 pros is 10,000 geocoding credits, returning 10,000 lat/lng pairs that you then feed to 10,000 individual isoline calls (one per pro).
The batch workflow is:
- Export your pro table as
pro_id,base_addressCSV - Upload to the WEB batch geocoder at csv2geo.com — select forward geocoding, one credit per row
- Download the result:
pro_id,base_address,lat,lng,confidence - Filter out rows where
confidence < 0.7— send those back to the pro for address correction - For the remaining rows, run the isoline calls in parallel (a modest concurrency of 20 simultaneous requests is reasonable; see concurrency tuning for the rationale)
- Write the returned polygons back to your pros table
A 10,000-pro backfill at 20 concurrent isoline requests takes roughly 8-10 minutes of wall-clock time. The geocoding step is near-instant in batch mode. The whole backfill runs in a single afternoon.
Step 4: Pull boundary tags for area-browse pages
Radius circles have one other problem beyond bad geometry: they produce no useful taxonomy for browse pages. "Plumbers within 15 km" is not a page a user or a search engine finds meaningful. "Plumbers in Southwark" is.
CSV2GEO's Boundaries/Divisions endpoint returns the named administrative areas that contain a given coordinate — borough, district, county, region, whatever the relevant hierarchy is for the country. You call it once per pro base address and store the tags. Those tags power your area-browse page taxonomy.
curl -s "https://csv2geo.com/api/v1/boundaries" \
-G \
--data-urlencode "lat=51.4545" \
--data-urlencode "lng=-2.5879" \
--data-urlencode "api_key=$CSV2GEO_KEY"def fetch_boundary_tags(lat: float, lng: float) -> list[dict]:
r = requests.get(
f"{API}/boundaries",
params={"lat": lat, "lng": lng, "api_key": KEY},
timeout=15,
)
r.raise_for_status()
return r.json().get("divisions", [])
# Returns list of {level, name, slug} — e.g.
# [{level: "borough", name: "Southwark", slug: "southwark"},
# {level: "county", name: "Greater London", slug: "greater-london"}]Store the slug values in a pro_boundary_tags join table. Your area-browse page for /plumbers/southwark queries: "give me all pros whose boundary tags include southwark, ordered by rating." The slug also forms the SEO URL, the <h1>, the breadcrumb, and the structured data areaServed value. One API call per pro base address produces all of it.
The boundary tags and the isoline polygon are complementary, not redundant. The polygon is for runtime matching — "does this job's coordinate fall inside this pro's coverage area?" The boundary tags are for taxonomy, browse, and SEO — "which pros should appear on the Southwark plumbers page?" Both are generated from the same base coordinate; both are stored once and updated on address change.
Step 5: Geocode incoming job requests and run the match
Every job request has an address field. Geocode it once, at submission time, and store the lat/lng. Do not geocode at query time in a hot path; geocode asynchronously on submission and hold the job in a pending_geocode state for the few hundred milliseconds that takes.
def process_job_submission(job_id: str, address: str) -> None:
try:
coords = geocode_address(address)
except ValueError as e:
mark_job_address_invalid(job_id, reason=str(e))
return
save_job_coords(job_id, coords["lat"], coords["lng"])
# Point-in-polygon: your code, your rules.
# PostGIS example:
# SELECT pro_id
# FROM pros
# WHERE ST_Contains(
# ST_GeomFromGeoJSON(coverage_polygon_geojson),
# ST_Point(%s, %s)
# )
# AND coverage_polygon_geojson IS NOT NULL
matching_pros = find_pros_covering_point(coords["lat"], coords["lng"])
ranked = rank_matching_pros(job_id, matching_pros)
save_coverage_matches(job_id, ranked)
notify_top_ranked_pros(job_id, ranked[:3])A few observations on this pattern.
The geocode is the only CSV2GEO call in the job submission path. Everything downstream — point-in-polygon, ranking, notification — is your application code against your own database. The geocoding call is fast and cacheable; if the same address appears in multiple job requests (a recurring customer, a property manager with many units), caching the geocode result eliminates repeat spend. The caching pattern is covered in caching geocoding results — 90% cost reduction.
Rank the matches in your code, not in the API. Your ranking formula — rating, response rate, distance to job, price tier, subscription level — is your competitive logic. It belongs in your application, not in a geocoding API call. The API's job is to give you accurate coordinates and accurate polygons; your job is to decide what to do with the matches.
Handle the no-match case explicitly. A job address that falls outside every pro's coverage polygon is not an error. It is a gap in your supply. Surface it to your ops team as a "coverage gap" event — these events are where you recruit the next pro.
Handling pro mobility: when the base changes
Pros move. A sole-trader plumber who moves house has a new base address, a new isoline, and newly-covered postcodes. The update path is:
- Pro saves new address in their profile
- Your API receives the update, calls
geocode_addresson the new address - On success, calls
fetch_isolinewith the new coordinates and their current drive-time setting - Overwrites
base_lat,base_lng,coverage_polygon_geojson, andcoverage_updated_at - Re-fetches boundary tags and updates
pro_boundary_tags - Invalidates any cached browse pages that listed this pro under old boundary slugs
Step 6 is the one teams miss. If you cache your area-browse pages — and you should, they are high-read low-write — you need a cache invalidation strategy keyed on pro boundary tags, not just pro ID. The simplest approach: store the list of boundary slugs the pro appeared under before the update, and purge those pages from your CDN after the polygon update. The new polygon may add new slugs (the pro now covers a neighbouring borough) and remove old ones — handle both.
SEO and browse page architecture
The boundary tag system gives you a clean URL taxonomy for area-browse pages. A reasonable structure:
/[service]/[country]/[region]/[city]/[borough]
/plumbers/uk/england/london/southwark
/plumbers/uk/england/london/lambeth
/electricians/uk/england/bristol/cliftonEach page queries your pro_boundary_tags table for pros matching the full tag hierarchy, ordered by rating. The page's areaServed structured data uses the canonical name from the Boundaries response, not a human-invented slug. The breadcrumb maps directly to the URL hierarchy.
This matters for trust as much as for SEO. A job-seeker browsing "plumbers in Southwark" wants to see pros who actually cover Southwark — not pros whose 15 km radius happens to clip a corner of Southwark's boundary. The combination of isoline-based coverage polygons (pro genuinely drives to Southwark within their time budget) and boundary-tag filtering (pro's base is classified as being in Southwark or in an adjacent area whose polygon overlaps Southwark) gives you a browse page you can stand behind.
Observability: what to instrument
A coverage-area system has a few non-obvious failure modes. Instrument these:
Null polygon rate. What percentage of active pros have coverage_polygon_geojson IS NULL? This should be near zero. A spike means the Isolines endpoint errored during a bulk update and your retry queue is not draining. See observability for geocoding pipelines for the full metrics shape.
Coverage gap rate. What percentage of submitted jobs have zero matching pros? Track this by geography — a gap rate above some threshold in a specific city signals under-supply in that area. Feed it to your recruiter dashboard.
Geocoding confidence distribution on job requests. How many incoming job addresses geocode with confidence below 0.7? These are addresses your matching logic is running against a dubious coordinate. Surface them to the ops team for manual validation rather than silently matching against the wrong point.
Isoline recomputation lag. When a pro saves a new base address, how long until their new polygon is stored? This should be seconds. A queue backlog here means pros are displaying stale coverage to job-seekers while the new polygon is computing.
Rate-limiting behaviour under burst load is worth testing explicitly — a sudden influx of new pro registrations all triggering isoline computation simultaneously is a predictable traffic pattern. The token-bucket behaviour of the API under that scenario is documented in rate limiting: token bucket vs leaky bucket.
What this architecture does not do
Honest scope, in the same spirit as the reference posts above.
It does not handle real-time traffic. The drive-time isoline is computed against typical traffic conditions at request time, or against a time-of-day model if the API supports it. It is not recalculated every five minutes against live road conditions. For a marketplace where a pro's drive-time commitment is a day-or-more booking, this is fine — the typical-conditions polygon is the right promise. For same-hour emergency callouts, you would want a traffic-aware isoline recalculated at dispatch time, which is a more expensive operation and a different product.
It does not route jobs to pros. The coverage area tells you who is eligible; your ranking logic tells you who gets notified first. The actual routing — optimising a pro's day with multiple booked jobs — is a vehicle routing problem that lives entirely in your application. The dispatch console post covers the geocoding side of that problem.
It does not handle polygons at country scale. If a pro claims they will travel anywhere in the UK, a 30-minute drive-time isoline is wrong and a radius circle is also wrong. The right model for nationally-operating pros is a set of service postcodes or a manually-drawn polygon, neither of which the Isolines endpoint generates. Design your pro onboarding to ask for a realistic drive-time budget and flag inputs above 90 minutes for manual review.
Frequently Asked Questions
Why not just use a radius circle and let pros adjust it?
Because pros adjust it to compensate for the lie. They expand the circle to reach motorway-accessible jobs outside their nominal range, and contract it to avoid river-crossing jobs inside it. After six months of adjustments they have a circle that fits no real mental model. The isoline is the honest geometry that matches what the pro already knows about their travel time; you do not need to train them to use it correctly.
How often should the isoline be recomputed?
Only when the pro changes their base address or their drive-time budget. The road network does not change on weekly timescales. Recomputing on a schedule is wasted spend and introduces the risk of a polygon changing under a pro without their knowing — which erodes trust. Recompute on change; cache everything else.
What drive-time values should I offer pros?
15, 30, 45, and 60 minutes covers the distribution for most marketplace categories. A carpet cleaner with equipment in a van works differently from a plumber who walks to nearby jobs, which works differently from a specialist who travels regionally. Offer the four values as a slider and let the pro choose. Do not offer above 90 minutes without gating it — a 90-minute isoline in a city like London is large enough that it becomes meaningless for job-seeker expectations.
The matching query will be slow at scale — what indexes do I need?
For PostGIS, a GIST index on coverage_polygon_geojson::geometry and an index on status (to exclude inactive pros) gives you sub-millisecond point-in-polygon queries at tens of thousands of pros. For application-level matching with Shapely or Turf.js, pre-parse the GeoJSON polygons into geometry objects at startup rather than parsing them on every query.
How do I handle a job address that the geocoder returns with low confidence?
Do not run a low-confidence geocode through the coverage matching pipeline. Mark the job as address_needs_review, surface it to the job-seeker with a "we couldn't confirm your address — can you check it?" message, and re-run the geocode on their corrected input. A bad geocode matched against pro polygons produces a mis-match that neither party can explain when the pro turns up at the wrong location.
Can I use the Boundaries endpoint for the pro's coverage area instead of isolines?
For some use cases, yes. If your platform operates at a city-district or borough level — "this electrician covers Brixton and Clapham" — boundary polygons are simpler to store and render, and the semantics are easier for job-seekers to understand ("Brixton" is meaningful; "30-minute drive from SW2 1AA" is not to a non-driver). The two approaches are not mutually exclusive: use boundary tags for browse and SEO, and the isoline polygon for precise runtime matching.
Does this architecture work for platforms that are not UK-based?
Yes, with two caveats. The Isolines endpoint covers all 63 countries in CSV2GEO's routing coverage. The Boundaries endpoint returns the local administrative hierarchy — arrondissement in Paris, ward in Tokyo, suburb in Sydney. Your URL taxonomy and browse page structure will need to be parameterised per country rather than hard-coded to the UK example shown above. The geocoding, isoline, and boundary calls all work identically across all covered countries.
Related Articles
- Dispatch console: routing 5,000 stops per day — the single-fleet dispatch counterpart to marketplace coverage matching
- Geocoding confidence scores explained — why the confidence gate on base-address geocoding is not optional
- Caching geocoding results — 90% cost reduction — how to cache job-request geocodes and coverage polygons without serving stale data
- Rate limiting: token bucket vs leaky bucket — managing burst isoline computation for large pro roster onboarding
- Observability for geocoding pipelines — what to instrument in a coverage-area system and how to set sensible alert thresholds
---
*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 →