Franchise territory mapping with boundaries and isolines

Define franchise territories from postcode boundaries or drive-time isolines, geocode every lead, and route it to the right franchisee automatically.

| July 21, 2026
Franchise territory mapping with boundaries and isolines

Franchise territory disputes are expensive. They move slowly through legal, they damage franchisee relationships, and they are almost always downstream of a much simpler engineering failure: someone drew the territories in a spreadsheet, someone else entered leads manually, and nobody ever built the point-in-polygon check that would have told both parties, in milliseconds, exactly which franchisee owns which lead.

The fix is not complicated. Every lead has an address. Every address can be geocoded to a lat/lng. Every territory has a boundary — either a set of administrative divisions like postcodes or counties, or a drive-time isoline drawn from the franchisee's location. A point-in-polygon test against that boundary is deterministic, auditable, and fast enough to run at lead-submission time. The dispute never starts because the assignment is logged, timestamped, and reproducible.

This post walks through building that system end to end using CSV2GEO's Boundaries, Isolines, and forward-geocoding endpoints. By the end you will have a working lead-routing pattern in Python and Node, a clear model for which territory shape (postcode vs drive-time) fits which franchise type, and an honest account of where the engineering gets awkward.

Why territory shape matters before you write a line of code

The choice between postcode-based territories and drive-time territories is not aesthetic — it determines the data model, the API surface, and the complexity of the edge-case handling. Get this wrong in week one and you will re-architect it in month six.

Postcode territories are the right default for franchise networks where the territories were originally drawn by a franchise development team using ZIP codes or postcode sectors. They are easy to explain to franchisees ("you own these twelve postcodes"), easy to audit ("here is the list"), and easy to represent in a relational database (a simple table of franchisee_id, postcode). The API surface is the Boundaries/Divisions endpoint — you look up which postcode contains the lead's geocode, then join that postcode to the ownership table. The assignment logic lives in your code, not in the API; the API tells you the containing boundary, and your database tells you who owns it.

Drive-time isolines are the right shape for franchise networks where territory is defined by reach — a service van, a home cleaner, a mobile healthcare provider, a pest control operator. The territory for franchisee A is "every address reachable within 20 minutes of drive time from their depot." These territories are irregular, they do not follow administrative lines, and two neighbouring franchisees may have overlapping isolines at their edges, which is a design decision the franchisor needs to make explicit before the engineering starts. The API surface here is the Routing Isolines endpoint — you request the polygon for a given origin and drive time, store the polygon, and at lead time you test the incoming geocode against it.

The third shape — hybrid — is where real franchise networks live. A territory is defined as "postcodes X, Y, and Z, plus any address within 15 minutes of the franchisee's location that falls outside those postcodes but within the contracted county." This is harder to build but it is the honest model for many mature franchise systems that have patched their territories over years of expansion. The pattern below handles this; the key is to resolve postcode-based rules first and fall through to the isoline check only for the unresolved cases.

The API surfaces you are working with

Three endpoints, used in sequence for every inbound lead.

`GET /api/v1/geocode` — forward geocoding. You give it an address string; it returns a lat/lng, a confidence score, and a structured breakdown of the address components. This is the entry point: no geocode, no territory assignment.

`GET /api/v1/boundaries` and `/api/v1/boundaries/divisions` — the boundary suite. You give it a lat/lng and a boundary type (postcode, county, state, municipality, or similar administrative level); it returns the containing boundary's identifier, name, geometry, and optionally its parent and child boundaries. For a postcode-based territory system, the query you run for every lead is "what postcode contains this lat/lng" — a single call that returns the postcode string, which you join against your ownership table.

`POST /api/v1/routing/isoline` — drive-time polygon generation. You give it an origin lat/lng, a mode (driving, cycling, walking), and a time or distance threshold; it returns a GeoJSON polygon representing the reachable area. For an isoline-based territory system, you call this once when a franchisee's territory is established, store the polygon, and use standard PostGIS or Turf.js point-in-polygon tests at runtime.

All three share the same API key. The pricing page at csv2geo.com/pricing/api covers all endpoints. The free tier gives you 3,000 calls per day — enough to prototype a full lead-routing workflow against a realistic lead volume before committing a credit card.

Drawing postcode territories: the Boundaries endpoint

A concrete curl call to establish the pattern. You have a lead at a given coordinate; you need to know which postcode it sits in.

curl -G "https://csv2geo.com/api/v1/boundaries" \
  --data-urlencode "lat=51.5074" \
  --data-urlencode "lng=-0.1278" \
  --data-urlencode "types=postcode" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

The response includes the postcode identifier, its name, and (if you request it) the full boundary geometry as a GeoJSON polygon. The identifier is the join key to your ownership table.

The same call in Python:

import os
import requests

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

def get_containing_postcode(lat: float, lng: float) -> dict | None:
    r = requests.get(
        f"{API}/boundaries",
        params={
            "lat": lat,
            "lng": lng,
            "types": "postcode",
            "api_key": KEY,
        },
        timeout=15,
    )
    r.raise_for_status()
    data = r.json()
    results = data.get("results", [])
    return results[0] if results else None

And in Node:

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

async function getContainingPostcode(lat, lng) {
  const url = new URL(`${API}/boundaries`);
  url.searchParams.set('lat', lat);
  url.searchParams.set('lng', lng);
  url.searchParams.set('types', 'postcode');
  url.searchParams.set('api_key', KEY);

  const r = await fetch(url.toString());
  if (!r.ok) throw new Error(`boundaries ${r.status}`);
  const data = await r.json();
  return data.results?.[0] ?? null;
}

The routing logic — "does this postcode belong to franchisee A or franchisee B?" — lives entirely in your database. CSV2GEO returns the containing boundary; your application layer owns the assignment. This is the right separation of concerns: the API is a spatial query engine, not a CRM.

Drawing drive-time territories: the Isolines endpoint

For a drive-time territory, you call the isoline endpoint once at territory-setup time and persist the resulting polygon. You do not re-call it for every lead — that would be slow and expensive. You call it once per franchisee location when territories are established or updated, store the GeoJSON polygon in your database alongside the franchisee record, and test incoming leads against the stored polygon at runtime.

Establishing a territory for a new franchisee:

curl -X POST "https://csv2geo.com/api/v1/routing/isoline" \
  -H "Content-Type: application/json" \
  -d '{
    "lat": 53.4808,
    "lng": -2.2426,
    "mode": "driving",
    "range": 1200,
    "range_type": "time",
    "api_key": "'"$CSV2GEO_API_KEY"'"
  }'

range: 1200 is 1,200 seconds — a 20-minute drive-time territory. The response is a GeoJSON Feature with a Polygon geometry. Store this geometry in a PostGIS geography column against the franchisee record.

The same call in Python:

def create_drive_time_territory(lat: float, lng: float, minutes: int) -> dict:
    r = requests.post(
        f"{API}/routing/isoline",
        json={
            "lat": lat,
            "lng": lng,
            "mode": "driving",
            "range": minutes * 60,
            "range_type": "time",
            "api_key": KEY,
        },
        timeout=30,
    )
    r.raise_for_status()
    return r.json()  # GeoJSON Feature with Polygon geometry

Once the polygon is stored, lead assignment at runtime is a PostGIS query:

SELECT f.franchisee_id, f.name
FROM franchisees f
WHERE ST_Contains(
    f.territory_geom,
    ST_SetSRID(ST_MakePoint(:lng, :lat), 4326)
)
ORDER BY f.priority ASC
LIMIT 1;

The priority column handles overlapping territories in a hybrid model — postcode rules win over isoline rules, and a predetermined priority rank resolves ties within the isoline layer.

If you are not running PostGIS, the equivalent in JavaScript using Turf.js is turf.booleanPointInPolygon(point, polygon). The geometry is GeoJSON; any library that understands GeoJSON can do the test. The point is that the spatial computation happens in your application layer, against polygons you have already stored — not in the API at lead time.

The full lead-routing flow, end to end

With the territory shapes defined and stored, the per-lead pipeline is three steps.

Step 1: Geocode the incoming lead address

Every form submission, every CRM webhook, every API ingest starts here. One call per lead.

def geocode_lead(address: str) -> dict | None:
    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:
        return None
    best = results[0]
    if best.get("confidence", 0) < 0.7:
        # Flag for manual review; do not attempt territory assignment
        return None
    return best

Confidence below 0.7 means the geocode is ambiguous — a partial address, a mistyped postcode, a street name that exists in multiple towns. Flagging these for manual review is correct; a wrong geocode produces a wrong territory assignment, which is worse than no assignment. For a deeper treatment of confidence scores see Geocoding Confidence Scores Explained.

Step 2: Identify the containing boundary or territory polygon

For postcode territories, call the Boundaries endpoint as shown above and join the returned postcode to your ownership table. For drive-time territories, run the PostGIS point-in-polygon query against stored isoline geometries. For the hybrid model, run the postcode lookup first; if it returns a match, stop. If the postcode is unassigned (gaps in the territory map do exist — more on this below), fall through to the isoline check.

def assign_territory(lat: float, lng: float) -> str | None:
    # Layer 1: postcode-based assignment
    postcode_result = get_containing_postcode(lat, lng)
    if postcode_result:
        postcode = postcode_result.get("name")
        franchisee = db.query(
            "SELECT franchisee_id FROM postcode_territories WHERE postcode = %s",
            [postcode]
        ).fetchone()
        if franchisee:
            return franchisee["franchisee_id"]

    # Layer 2: isoline-based assignment (fallback)
    franchisee = db.query(
        """
        SELECT franchisee_id FROM franchisees
        WHERE ST_Contains(territory_geom,
            ST_SetSRID(ST_MakePoint(%s, %s), 4326))
        ORDER BY priority ASC LIMIT 1
        """,
        [lng, lat]
    ).fetchone()
    return franchisee["franchisee_id"] if franchisee else None

Step 3: Route the lead and log the assignment

The result of assign_territory goes into your CRM, your ticketing system, your email routing rule — wherever leads land in your franchise stack. Critically, log every assignment with the full audit trail: lead ID, geocoded lat/lng, geocoding confidence, containing boundary identifier, franchisee assigned, and timestamp. When a franchisee raises a dispute in six months, the audit log is the evidence.

import time

def route_lead(lead_id: str, address: str) -> dict:
    geo = geocode_lead(address)
    if geo is None:
        return {"status": "manual_review", "reason": "low_confidence_geocode"}

    lat, lng = geo["lat"], geo["lng"]
    franchisee_id = assign_territory(lat, lng)

    log_entry = {
        "lead_id": lead_id,
        "address": address,
        "lat": lat,
        "lng": lng,
        "geocode_confidence": geo.get("confidence"),
        "franchisee_id": franchisee_id,
        "assigned_at": int(time.time()),
    }
    db.insert("lead_routing_log", log_entry)

    if franchisee_id is None:
        return {"status": "unassigned", "reason": "no_territory_match", **log_entry}

    return {"status": "assigned", **log_entry}

Step 4: Re-check your existing lead book with the WEB batch tool

When you first deploy this system, you almost certainly have a backlog of leads that were assigned manually or not assigned at all. CSV2GEO's WEB batch tool lets you upload a CSV of address rows, geocode them all, and download the enriched file with lat/lng and boundary data appended. Credits are consumed per address row. A backlog of 10,000 leads costs 10,000 credits — at paid pricing from $54/month for 100,000 calls, that is roughly $5.40 for the whole historical cleanup run.

Run the batch, import the enriched CSV, and re-run your assign_territory logic against the geocoded coordinates. Any disagreements between the manual historical assignment and the deterministic geocode-plus-boundary assignment are the candidates for a retrospective territory-audit conversation with your franchisees — better to surface those proactively than to have them raised as disputes mid-contract.

Step 5: Keep territory geometry current when franchisee locations change

A franchisee relocates their depot. Their drive-time territory shifts. If you do not update the stored polygon, leads near the boundary of the old and new locations will route incorrectly for months before anyone notices.

Build a simple admin endpoint in your franchise management system: when a franchisee's registered location changes, call the isoline endpoint to regenerate the polygon, write the new geometry to the database, and log the change with a timestamp. The isoline call is one HTTP request and costs one credit. The cost of not doing it is a territory dispute.

Gap territories, border leads, and other awkward edge cases

Three failure modes that will definitely bite you if you do not design for them explicitly.

Gaps in the territory map. In any large franchise network, some postcodes are unassigned. They might be genuinely unawarded territories, or they might be postcodes that exist in the boundary dataset but were never included in any franchisee's list. A lead that geocodes into one of these falls through every layer of the assignment logic and returns null. The correct behaviour is to queue it to a central "unassigned leads" pool managed by the franchisor, not to silently drop it. Build the unassigned-leads pool from day one; it is always populated.

Border confidence. A lead that geocodes to a lat/lng within 50 metres of a postcode boundary is ambiguous — the geocoder is not that precise, and the address might honestly be on either side of the line. The cleanest approach is to surface these to the assigned franchisee with a flag: "This lead is within 100 m of your territory boundary. Please confirm with the customer before committing to service." The 100 m threshold is a design choice; match it to the precision your geocoding confidence scores support. See Geocoding Confidence Scores Explained for how to extract a useful spatial uncertainty estimate from the API response.

Overlapping isolines. If two franchisees' drive-time territories overlap — common in dense urban markets — the assignment rule needs to be explicit and documented in the franchise agreement before you build it into the routing code. Priority-rank is the simplest model: franchisee A's territory wins over franchisee B's in the overlap zone, full stop. First-come-first-served (whoever created their territory first wins the overlap) is also defensible. What is not defensible is leaving it as "whoever processes the lead first" — that is how disputes start.

Caching and cost management

Territory boundaries do not change often. A postcode boundary geometry you fetched today is the same geometry you would fetch next week. Cache it.

A practical caching strategy for the Boundaries endpoint: cache by (lat_rounded_to_4dp, lng_rounded_4dp, boundary_type) with a TTL of 30 days. Rounding to four decimal places (roughly 11 m precision) means that two leads from the same building hit the same cache entry. Four decimal places is precise enough that no address pair sharing the same rounded coordinate straddles a postcode boundary in any real-world scenario.

For isoline geometries, cache the polygon in your database against the franchisee record. The TTL is "whenever the franchisee's location changes" — which is rare — rather than a fixed time window. One isoline call per franchisee per location change is negligible cost.

The general caching argument: geocodes are idempotent — the same address returns the same coordinates, and the same coordinate returns the same boundary. Cache aggressively. For the full treatment, Caching Geocoding Results — 90% Cost Reduction covers every layer of the caching stack that applies here.

Observability for a live routing system

A lead-routing pipeline that silently misassigns leads is worse than one that fails noisily. Four metrics to instrument from the start.

Assignment rate. The fraction of incoming leads that receive a franchisee assignment without falling through to the unassigned pool. Below 95% signals either a gap in the territory map or a geocoding quality problem with your lead-capture form.

Confidence distribution. Histogram of geocoding confidence scores across all leads. A shift toward lower confidence scores signals that your lead-capture form has changed in a way that degrades address quality — or that a particular acquisition channel is sending partial addresses.

Boundary-lookup latency. The p95 time from address submission to franchisee assignment. If this climbs, the most common cause is an uncached boundary lookup that hits the API on every lead.

Retry rate. If your retry logic (see Exponential Backoff — When to Retry, When to Stop) is firing more than 1% of the time, you have a capacity or network problem upstream of the routing logic.

The broader observability framework for geocoding pipelines is covered in Observability for Geocoding Pipelines.

What this architecture does not do

Honest scope. The pattern above handles the spatial problem: given an address, deterministically identify the owning franchisee. It does not handle:

CRM routing and notification. The API returns a franchisee ID; your CRM integration delivers the lead to them. That integration is out of scope for the geocoding layer — it belongs to your franchise management platform or your CRM middleware.

Territory enforcement in contracts. Spatial assignment is not a legal defence by itself. The franchise agreement needs to define the territory precisely — "the postcodes listed in Schedule A" or "the area within 20 minutes of drive time from the registered premises address on file at the Effective Date" — and reference the system's output as the authoritative resolver. Get franchise legal counsel to review the language; the engineering implements what the contract defines.

Real-time lead bidding. Some franchise models allow franchisees to bid on out-of-territory leads. That is an auction system built on top of the routing layer, not part of it. Build the assignment layer first; build the bidding layer only if the franchise model requires it.

Frequently Asked Questions

Can territories be based on counties rather than postcodes? Yes. The Boundaries endpoint supports multiple administrative levels — postcodes, counties, municipalities, and others depending on the country. Swap types=postcode for types=county in the query. The join logic in your database is identical; only the granularity changes.

What happens when a lead's address geocodes with low confidence? Flag it for manual review rather than attempting territory assignment. A low-confidence geocode means the spatial coordinates are uncertain; running a boundary lookup against uncertain coordinates produces an assignment you cannot defend in a dispute. Route it to the franchisor's central queue.

How often do postcode boundary geometries change? Infrequently — postal authorities redefine postcode boundaries a few times per decade, usually in new-development areas. Cache boundary geometry aggressively (30-day TTL is safe) and re-pull when a franchisee raises a boundary question.

Our franchise network spans 15 countries. Does the Boundaries endpoint cover all of them? CSV2GEO covers 63 countries across the Boundaries suite. Check the documentation for the specific administrative levels available in each country — not every country exposes postcode-level boundaries; some networks may need to fall back to municipality or county level in certain markets.

Is there a batch option for the Boundaries endpoint? The WEB batch tool handles address-level enrichment in bulk — upload a CSV, download coordinates and boundary data. For high-volume programmatic pipelines, geocoding and boundary lookups are separate per-row API calls; batch them in your application layer using concurrency patterns covered in Concurrency Tuning for Geocoding Pipelines.

How precise are drive-time isolines in dense urban areas? Drive-time isolines are routing-engine results, not straight-line buffers, so they respect one-way streets, motorway junctions, and traffic-pattern-based speed estimates. In dense city centres, a 20-minute isoline will be noticeably smaller and more irregular than in a suburban environment. This is correct behaviour; it is the point of using drive time rather than distance radius. Build your franchise agreement language around the isoline output ("the area routable within N minutes") rather than trying to describe the resulting polygon shape.

What if two franchisees dispute an assignment that the system already logged? The audit log is the evidence. The assignment is reproducible: re-run the geocode for the lead address, re-query the boundary at the same timestamp, and confirm the output matches the log. If it does, the system behaved correctly and the dispute is a contract interpretation question, not a technical one. If it does not match, you have found a data-consistency bug that needs fixing.

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 →