Address serviceability checks for telecom footprints

Geocode a customer address, test it against your service footprint polygons client-side. REST patterns for ISPs and telcos in Python, Node, and curl.

| June 29, 2026
Address serviceability checks for telecom footprints

Every ISP and telco answers the same question a million times a day: is this address inside our service footprint? The question looks trivial until you run the query at scale — half a million address lookups per day during a marketing campaign, forty thousand concurrent sign-up form submissions when you announce a new market, and a back-office batch job that runs nightly to refresh the serviceability status of every address in the CRM.

The fragile version of this system is a postcode list in a database table. The sales team adds postcodes by hand when they expand a market. The marketing team cleans out postcodes when a tower goes down. Inevitably, someone submits an order for an address that is technically within the postcode boundary but is physically on the wrong side of a motorway or outside the last-mile network topology. The order accepts. The engineer shows up on the date. Nothing can be connected. The customer churns. The NPS tanks.

The correct version of this system geocodes the customer's address to a precise coordinate, then tests that coordinate against the actual service-area polygons your network team already has. Postcodes and admin boundaries are useful secondary signals — they are cheap, cache well, and let you pre-filter large datasets quickly — but they are not a substitute for a real point-in-polygon test against your topology.

This post walks through the architecture end-to-end, with working REST examples. By the end you will have a Python script that takes an address and returns "serviceable": true/false against whatever polygon file your network team maintains.

What the system actually needs to do

Before touching code, be precise about the question. There are at least four distinct serviceability questions hiding behind one marketing sentence:

Can we physically connect a line? This is a network topology question. The answer lives in your network's GIS — your fibre routes, node placements, last-mile coverage polygons. No external API can answer this; it is your data.

Is the address inside our commercial licence area? Many telcos have regulatory constraints on where they can and cannot sell. This is often expressed as an administrative boundary — a county, a state, a regulatory zone. The CSV2GEO Boundaries endpoint can return the admin hierarchy for any address and let you match against a list of permitted admin codes.

Is the address real and uniquely resolvable? A user who types "123 Main St" into a sign-up form might mean any of several dozen streets in the country. Geocoding with a confidence score tells you whether the address resolves to a single point with high certainty or whether it is ambiguous.

Is the address within a postcode we have already agreed to serve? Postcode-level serviceability is often the first cut — it is cheap, cacheable, and lets you eliminate 80% of out-of-footprint queries before they reach the expensive polygon test.

A production serviceability system uses all four. The rest of this post shows how geocoding and the Boundaries endpoint handle questions two, three, and four, leaving question one entirely in your hands — where it belongs.

The three endpoints you will use

`GET /api/v1/geocode` — forward geocoding. Takes a free-text or structured address, returns a coordinate, a confidence score, and a normalised address. This is the entry point. Every serviceability check starts here.

`GET /api/v1/reverse`reverse geocoding. Takes a coordinate, returns address and admin context. Useful when your CRM already has lat/lng and you need to enrich it with postcode and admin codes for boundary matching.

`GET /api/v1/boundaries` — returns postcode boundaries and the administrative hierarchy (country, region, county, district) for a given coordinate or postcode code. This is the right endpoint for "which admin zone does this address fall in, and is that zone on our permitted list?" It returns polygon geometry if you ask for it, which you can cache and use for client-side point-in-polygon without round-tripping per address.

Notice what is not in the list: a serviceability endpoint. CSV2GEO geocodes addresses and returns admin context. Whether a specific geocoded coordinate is inside your particular network footprint is a question only you can answer, because only you have the network topology. The pattern is: use the API for the geocoding and the admin context, then test the result against your own polygons on your own infrastructure.

How point-in-polygon fits in

After geocoding an address you have a coordinate — (lat, lng) to five or six decimal places. To test whether that coordinate is inside a service-area polygon, you run a point-in-polygon test. This is standard GIS geometry and there is no reason to push it to an external API; it runs in microseconds in-process.

In Python, Shapely is the standard library:

from shapely.geometry import Point, shape

# polygon_geojson is a GeoJSON dict you have loaded from your network GIS export
service_area = shape(polygon_geojson)
candidate = Point(lng, lat)  # Shapely takes (x, y) = (lng, lat)
is_serviceable = service_area.contains(candidate)

In Node, Turf.js does the same job:

import * as turf from '@turf/turf';

// polygon is a GeoJSON Feature<Polygon> from your network GIS export
const candidate = turf.point([lng, lat]);
const isServiceable = turf.booleanPointInPolygon(candidate, polygon);

Both libraries handle multi-polygon footprints (multiple non-contiguous service zones), holes in polygons (a park or industrial site inside a service zone where you cannot install), and projection edge cases. Load your footprint polygons at application startup, cache them in memory, and the per-address test is microseconds — no additional API call, no additional latency, no additional cost.

Step-by-step: building a serviceability check

Step 1 — geocode the customer's address

The entry point. The customer types an address into a sign-up form. Your backend receives a string; you need a coordinate and a confidence score before you touch the footprint check.

curl -s "https://csv2geo.com/api/v1/geocode" \
  --data-urlencode "q=450 Serra Mall, Stanford, CA 94305" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY" \
  --get

A typical response looks like:

{
  "results": [
    {
      "lat": 37.4275,
      "lng": -122.1697,
      "confidence": 0.94,
      "formatted_address": "450 Serra Mall, Stanford, CA 94305, US",
      "postcode": "94305",
      "admin1": "California",
      "admin2": "Santa Clara County"
    }
  ]
}

Confidence below 0.7 means the address is ambiguous — it has resolved, but not uniquely. For a serviceability check that might trigger an engineer visit, ambiguous addresses should not proceed to serviceable: true. Return a "please verify your address" prompt and let the customer clarify. See Geocoding Confidence Scores Explained for the full rationale on threshold selection.

In Python:

import os
import requests

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

def geocode(address: str) -> dict | None:
    r = requests.get(
        f"{API}/geocode",
        params={"q": address, "api_key": KEY},
        timeout=10,
    )
    r.raise_for_status()
    results = r.json().get("results", [])
    if not results:
        return None
    top = results[0]
    if top.get("confidence", 0) < 0.7:
        return None  # ambiguous — caller should prompt for clarification
    return top

Step 2 — check postcode-level serviceability first

Before running the polygon test, run the cheap filter: is the postcode on your list? This cuts most out-of-footprint queries before they touch the polygon geometry at all.

You maintain a set of serviceable postcodes — call it SERVED_POSTCODES. The geocode response already returns postcode in the top-level result. The filter is:

def postcode_filter(geocode_result: dict, served_postcodes: set) -> bool:
    return geocode_result.get("postcode") in served_postcodes

For a national ISP that covers a few hundred postcodes, served_postcodes is a Python set that fits in memory and evaluates in nanoseconds. Load it from your database at startup and refresh it on a schedule or on a config push.

Why bother if you are also running a polygon test? Because polygon tests involve real geometry and real memory. A postcode filter is O(1). If 70% of your sign-up form traffic is from postcodes you definitely do not serve, the postcode filter keeps those off the Shapely/Turf codepath entirely. More importantly, it gives you a fast pre-check for marketing landing pages where you want to show "sorry, not in your area" without running the full geocode + polygon pipeline on every keypress.

Step 3 — fetch admin boundary context for compliance checks

Some serviceability decisions are not polygon-based — they are administrative. A telco licenced to operate in certain counties but not others needs to test the admin2 field, not a hand-drawn polygon. The Boundaries endpoint gives you the full admin hierarchy and, optionally, the polygon geometry for each admin level.

curl -s "https://csv2geo.com/api/v1/boundaries" \
  --data-urlencode "lat=37.4275" \
  --data-urlencode "lng=-122.1697" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY" \
  --get

The response includes the admin hierarchy — country, state/region, county, district — with standardised codes. If your compliance list is "we are licenced in Santa Clara County and San Mateo County," you match on the county code from the response. That is a string comparison, not a polygon test, and it is cacheable at the postcode level.

In Node:

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

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

  const r = await fetch(url.toString());
  if (!r.ok) throw new Error(`boundaries: http ${r.status}`);
  return r.json();
}

function adminFilter(boundaryResult, licencedCountyCodes) {
  const county = boundaryResult?.admin2_code;
  return licencedCountyCodes.has(county);
}

The Boundaries endpoint also returns polygon geometry for each admin level if you request it. Pull the county polygon once, cache it in memory, and you have a freely-reusable geometry for point-in-polygon tests that does not require a per-address API call.

Step 4 — run the polygon test against your network footprint

This is where your network topology data comes in. Your GIS team exports the service-area polygons as GeoJSON — one feature per service zone, multi-polygon format if you have non-contiguous zones. Load the file at application startup.

import json
from shapely.geometry import Point, shape
from shapely.strtree import STRtree

# Load your footprint once at startup
with open("service_footprint.geojson") as f:
    geojson = json.load(f)

polygons = [shape(feat["geometry"]) for feat in geojson["features"]]
tree = STRtree(polygons)  # spatial index for fast lookup


def polygon_test(lat: float, lng: float) -> bool:
    pt = Point(lng, lat)
    # STRtree query returns candidates that intersect the point's bounding box
    candidates = tree.query(pt)
    return any(polygons[i].contains(pt) for i in candidates)

The STRtree spatial index is the important part. Without it, a point-in-polygon test against a thousand service-zone polygons requires testing the point against each polygon in turn — O(n). With the spatial index, the query eliminates all polygons whose bounding box does not contain the point before the expensive contains() call — the practical cost is closer to O(log n), and for most real-world footprints it runs in well under a millisecond.

Step 5 — compose the final serviceability result

Wire the three checks together into a single function that the sign-up form backend calls:

def check_serviceability(address: str, served_postcodes: set,
                         licenced_county_codes: set) -> dict:
    geo = geocode(address)
    if geo is None:
        return {"serviceable": False, "reason": "address_unresolvable"}

    if not postcode_filter(geo, served_postcodes):
        return {"serviceable": False, "reason": "postcode_not_in_footprint"}

    lat, lng = geo["lat"], geo["lng"]

    # Optional: admin compliance check (skip if not needed)
    boundary = fetch_boundary_context(lat, lng)
    if not admin_filter(boundary, licenced_county_codes):
        return {"serviceable": False, "reason": "admin_zone_not_licenced"}

    # Final: polygon test against your network topology
    if not polygon_test(lat, lng):
        return {"serviceable": False, "reason": "outside_network_polygon"}

    return {
        "serviceable": True,
        "lat": lat,
        "lng": lng,
        "postcode": geo.get("postcode"),
        "confidence": geo.get("confidence"),
    }

Each layer filters before the next, so the expensive operations only run when the cheap ones pass. In practice, for a telco with well-defined postcode boundaries, the postcode filter kills 60–80% of out-of-footprint queries in microseconds. The polygon test runs only for addresses that pass the postcode check — which is exactly the population you care about getting right.

Caching strategy

Three things to cache, at three different TTLs.

Geocode results. An address that has been geocoded once returns the same coordinate until the address data is re-indexed. Cache on the normalised address string — lowercase, trimmed — with a TTL of 7 to 30 days. For a telco sign-up form receiving a thousand addresses per minute, a Redis cache with a 7-day TTL will yield 80%+ hit rates on addresses from dense urban coverage areas, because residents and businesses reuse address forms constantly. See Caching Geocoding Results — 90% Cost Reduction for the full caching architecture.

Postcode serviceability. If a postcode was serviceable at 09:00 today, it is almost certainly serviceable at 09:05. Cache the {postcode: serviceable} result with a TTL that matches your network change cadence — typically several hours. Invalidate on explicit network-change events from your provisioning system.

Admin boundary polygons. County and state polygons do not change. Pull them via the Boundaries endpoint once, serialise to disk, and load at startup. Refresh annually or when a boundary redistricting event is announced. There is no operational reason to call the Boundaries endpoint per address for admin polygons that you need every time.

Failure modes you will hit in production

The address resolves but to the wrong country. A user typing "Springfield" in a US sign-up form might get Springfield, Illinois, Springfield, Missouri, or Springfield, any of several dozen other states. A user typing a short street name without a city or postcode might get a result in a different country entirely. Always include postcode or city in the geocode request when you can surface it from the form, and always check the admin1 country code in the response before running a footprint test.

The confidence score is above your threshold but the coordinate is wrong. Confidence is a measure of address ambiguity, not of geocoding precision. A perfectly unambiguous address that has a formatting error can resolve to a plausible-sounding wrong location with a confidence of 0.8. For high-stakes serviceability decisions — the kind where an engineer is being dispatched — add a human verification step for any address that was reformatted significantly from the user's input (compare input_address to formatted_address in the response).

Your footprint polygon file becomes stale. The GeoJSON you loaded at application startup was current when the process started. If your network team updates a polygon boundary during the day — expanding into a new neighbourhood, contracting after an equipment failure — processes that loaded the old file are serving incorrect serviceability results. The fix is either a file-watch and hot-reload pattern, or a short-lived cache with a TTL that matches your network change frequency. Most teams use a 15-minute reload and accept the small window of staleness.

The polygon test passes but provisioning still fails. Point-in-polygon against your service-area polygon confirms the address is within the network's approximate coverage. It does not confirm that the specific drop point at this specific address is addressable. A handful of addresses within your polygon will hit provisioning failures because of last-mile topology gaps — a building that was never wired, a conduit issue from a previous permit dispute. The serviceability check should set the expectation honestly: "based on your address, our network covers this area" rather than "we guarantee service to this address." The guarantee belongs at provisioning, not at sign-up.

Building a bulk serviceability batch job

The sign-up form pattern above handles one address at a time in a synchronous web request. The other common pattern is a nightly batch job that refreshes the serviceability flag for every address in the CRM — a hundred thousand rows, run overnight.

The key insight is that the geocoding step is the only one that requires an external API call per address. The polygon test and the postcode filter are both in-process and essentially free. The batch job therefore shapes like:

import csv
import time

BATCH_SIZE = 100  # geocode API calls in parallel
SLEEP_BETWEEN_BATCHES = 0.5  # seconds, tune to your plan's rate limit

with open("crm_addresses.csv") as fin, \
     open("crm_serviceability.csv", "w", newline="") as fout:

    reader = csv.DictReader(fin)
    fields = reader.fieldnames + ["lat", "lng", "serviceable", "reason"]
    writer = csv.DictWriter(fout, fieldnames=fields)
    writer.writeheader()

    batch = []
    for row in reader:
        batch.append(row)
        if len(batch) >= BATCH_SIZE:
            for r in batch:
                result = check_serviceability(
                    r["address"], SERVED_POSTCODES, LICENCED_COUNTY_CODES
                )
                r.update(result)
                writer.writerow(r)
            batch.clear()
            time.sleep(SLEEP_BETWEEN_BATCHES)

    # flush remainder
    for r in batch:
        result = check_serviceability(
            r["address"], SERVED_POSTCODES, LICENCED_COUNTY_CODES
        )
        r.update(result)
        writer.writerow(r)

For a 100,000-row CRM, this runs in under an hour on a single process with the SLEEP_BETWEEN_BATCHES tuned to your rate plan. Use concurrent.futures.ThreadPoolExecutor with a modest worker count to parallelise the I/O-bound geocode calls; see Concurrency Tuning — Geocoding Sweet Spot for the tuning rationale.

What the API key and billing actually look like

A geocoding call is one credit. A Boundaries call is one credit. A polygon test is local compute — zero credits. Fetching the boundary polygon geometry for a county once and caching it means you spend one credit per county per refresh cycle, not one credit per address.

For a sign-up form handling 10,000 unique address queries per day, the daily bill is roughly 10,000 credits (geocoding) plus a small number of Boundaries credits for cache misses on admin context. At paid pricing starting from $54/month for 100,000 calls (see csv2geo.com/pricing/api), a 10,000-calls-per-day workflow costs under $20/day at the entry tier — less once you account for the cache hit rate.

The free tier gives you 3,000 calls per day with no credit card. That is enough to run a pilot against a sample of your CRM addresses before committing to a paid plan.

There is no separate serviceability endpoint, no special tier, no minimum commit for this use case. It is standard forward geocoding and Boundaries, billed the same way as any other workflow.

Frequently Asked Questions

Does CSV2GEO store our footprint polygons or serviceability decisions?

No. You supply your own footprint polygons and run the point-in-polygon test on your own infrastructure. CSV2GEO geocodes addresses and returns admin/postcode context — it has no visibility into your network topology, your serviceability outcomes, or the match logic you apply after geocoding. Your footprint data never leaves your systems.

How do I handle addresses that geocode to a lat/lng at the centroid of a postcode rather than the actual building?

Low-confidence geocodes and postcode-centroid results both have confidence scores below typical building-level resolution. Set your confidence threshold at 0.75 or above for serviceability decisions that trigger a physical dispatch. Addresses below the threshold should route to a human verification step rather than an automated serviceable: true. The Geocoding Confidence Scores Explained post covers threshold selection in detail.

Can the Boundaries endpoint return the polygon geometry for a service area?

It returns administrative and postcode boundary polygons for the admin hierarchy around a given coordinate. It does not know anything about your private network service areas — those are your polygons, managed in your GIS system. The use case for the Boundaries endpoint in a serviceability workflow is admin-zone compliance checking ("are we licenced to serve this county?"), not network topology testing.

What is the coverage for the forward geocoding endpoint?

The geocoding index covers 39 countries and 461 million addresses. For telecom operators with footprints in countries outside this set, the geocode will return no result or a low-confidence result for out-of-coverage addresses. Build an explicit no_result handling path.

Should the serviceability check run synchronously on the sign-up form or asynchronously in the background?

For sign-up flows where the result affects whether the user can proceed, run it synchronously and return within the user's tolerance for a form response (aim for under 400 ms total). Geocoding adds one network round-trip; the polygon test is local and sub-millisecond. For bulk CRM refreshes, run it asynchronously in a nightly batch. Do not run the full pipeline synchronously on every keypress in an address autocomplete field — debounce to fire only on form submit or after the user stops typing for 500 ms.

How do we handle retries when the geocoding API returns a transient error?

Use exponential backoff with jitter: start at 250 ms, double on each retry, cap at 8 seconds, give up after four attempts. Log the failure and return {"serviceable": false, "reason": "geocode_error"} to the caller rather than hanging indefinitely. For bulk batch jobs, log the failed rows and re-run them in a separate pass after the main job completes. See Exponential Backoff — When to Retry, When to Stop for the full implementation pattern.

Can we use the reverse geocoding endpoint to enrich CRM records that already have lat/lng?

Yes. If your CRM stores coordinates but not postcode or admin codes, call /api/v1/reverse with the stored lat/lng to get the full admin hierarchy. Then apply the postcode filter and admin compliance check without a forward geocoding step. This is the right pattern for records imported from a field system that captured GPS coordinates directly.

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 →