Which jurisdiction is this address in? Permitting and zoning lookups

Determine the county, municipality, and admin hierarchy for any address via REST. A practical guide for construction and permitting workflows.

| July 08, 2026
Which jurisdiction is this address in? Permitting and zoning lookups

A contractor submits a permit application to the wrong county office. The address read like it was in the city limits — same postal code, same city name in the mailing address — but the parcel sat in an unincorporated area governed by the county, not the municipality. The city's permit desk rejected it after two weeks of silence. The county desk took another week to process the resubmission. Three weeks lost, one small team demoralized, and a client starting to ask questions.

This happens constantly in construction permitting. The mailing address tells you how the post office delivers letters. It does not tell you which planning department issues your building permits, which fire marshal has jurisdiction over your sprinkler inspections, or which county assessor will be taxing the finished structure. For that you need the administrative hierarchy — the nested set of counties, municipalities, townships, and special districts that legally govern a parcel.

This post shows how to resolve that hierarchy for any address using the CSV2GEO forward geocoding and Boundaries endpoints, wire the result into a permitting intake workflow, and avoid the three failure modes that send permit applications to the wrong desk.

Why postal addresses are the wrong source of truth for jurisdiction

The mailing address is designed for mail delivery. It optimises for human readability and postal-route efficiency. Administrative jurisdiction is a legal question answered by surveyors, county recorders, and state statutes — and it frequently diverges from the mailing address in ways that matter enormously for permitting.

Consider a parcel straddling a county line. The mailing address carries the city and ZIP of whichever side the front door is on. The back half of the lot might be taxed by a different county entirely, with a different setback requirement and a different permit fee schedule. The postal address tells you nothing about this.

Or consider a city enclave — a pocket of incorporated city territory surrounded by unincorporated county land, a pattern common in fast-growing suburbs where annexation happened piecemeal over decades. A street address in one of these enclaves looks identical to a county address three blocks away. The difference is which planning department has authority.

Or consider a rural address on a road that crosses a township boundary. The road name might be the same on both sides; the parcel numbers are not; the zoning is not; the permit office is not.

The correct answer to "which jurisdiction is this address in?" comes from matching the parcel coordinates against authoritative administrative boundary polygons — not from parsing the mailing address.

What CSV2GEO gives you

CSV2GEO does not provide parcel boundaries, zoning data, or permit information. Those datasets belong to individual counties and municipalities; they vary enormously in format, freshness, and API availability, and any claim that a single API returns them globally would be false.

What CSV2GEO does provide is the administrative hierarchy above the parcel level: the county, municipality, state, and other administrative ancestors for any geocoded coordinate. This is the information you need to route a permit application to the correct government office, populate a dropdown of applicable permit types, or flag an address for manual review when the jurisdiction is ambiguous.

The workflow is two API calls:

  1. Forward geocode the address to get a verified lat/lng and a confidence score.
  2. Query the Boundaries endpoint at that lat/lng to get the full administrative ancestor hierarchy — country, state, county, municipality, and any intermediate levels that exist for the address.

You then map the hierarchy fields to your own permitting-rules table. The rules — which office to contact, which fee schedule applies, which setback distances govern — live in your system. The API resolves which administrative units the address belongs to; your business logic decides what that means.

The two-call pattern in code

Forward geocoding

Before querying administrative boundaries, you need a verified coordinate. The free-text address carries typos, missing unit numbers, and postal-route cities that do not match the administrative city name. A confidence score below a threshold should trigger manual review before any permit application goes anywhere.

curl -s "https://csv2geo.com/api/v1/geocode" \
  --get \
  --data-urlencode "q=4872 Old Mill Road, Springfield, OH 45502" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

Response shape (abbreviated):

{
  "results": [
    {
      "lat": 39.9242,
      "lng": -83.8088,
      "confidence": 0.94,
      "formatted_address": "4872 Old Mill Road, Springfield, OH 45502, USA",
      "address_components": {
        "house_number": "4872",
        "road": "Old Mill Road",
        "city": "Springfield",
        "county": "Clark County",
        "state": "Ohio",
        "postcode": "45502",
        "country": "US"
      }
    }
  ]
}

The county and city fields in address_components are a first-pass signal. But do not use them as your final jurisdiction determination — these are postal-context fields and may reflect the mailing city rather than the governing municipality. The Boundaries call below is the authoritative step.

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=15,
    )
    r.raise_for_status()
    results = r.json().get("results", [])
    if not results:
        return None
    best = results[0]
    if best["confidence"] < 0.75:
        # Low confidence — flag for manual review before submitting a permit
        best["_requires_review"] = True
    return best

In Node:

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

async function geocode(address) {
  const url = `${API}/geocode?q=${encodeURIComponent(address)}&api_key=${KEY}`;
  const r = await fetch(url);
  if (!r.ok) throw new Error(`geocode ${r.status}`);
  const body = await r.json();
  const best = body.results?.[0] ?? null;
  if (best && best.confidence < 0.75) best._requiresReview = true;
  return best;
}

For a detailed treatment of what confidence scores mean and which thresholds to apply in production, see Geocoding Confidence Scores Explained.

Boundaries — the administrative hierarchy

Once you have a verified lat/lng, query the Boundaries endpoint to resolve the full administrative hierarchy.

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

Response shape (abbreviated):

{
  "lat": 39.9242,
  "lng": -83.8088,
  "ancestors": [
    {"level": "country",      "name": "United States",  "code": "US"},
    {"level": "state",        "name": "Ohio",            "code": "US-OH"},
    {"level": "county",       "name": "Clark County",    "code": null},
    {"level": "municipality", "name": "Springfield",     "code": null},
    {"level": "suburb",       "name": "Old Town",        "code": null}
  ]
}

The level field uses a consistent vocabulary across the 39 countries covered by the API. County-equivalent levels in countries that do not use the word "county" still appear as county in the response so your application code does not need per-country branching logic for the hierarchy structure itself.

The ancestors array is ordered from broadest to most specific. The permitting-relevant levels for most US construction workflows are state, county, and municipality. If municipality is absent (the address is in an unincorporated area governed directly by the county), that absence is itself the answer: the county planning department has jurisdiction.

In Python, wiring the two calls together:

def get_jurisdiction(address: str) -> dict:
    geo = geocode(address)
    if geo is None:
        return {"error": "address_not_found"}

    r = requests.get(
        f"{API}/boundaries",
        params={"lat": geo["lat"], "lng": geo["lng"], "api_key": KEY},
        timeout=15,
    )
    r.raise_for_status()
    data = r.json()

    ancestors = {a["level"]: a["name"] for a in data.get("ancestors", [])}
    return {
        "lat": geo["lat"],
        "lng": geo["lng"],
        "confidence": geo["confidence"],
        "requires_review": geo.get("_requires_review", False),
        "country": ancestors.get("country"),
        "state": ancestors.get("state"),
        "county": ancestors.get("county"),
        "municipality": ancestors.get("municipality"),
        "is_unincorporated": "municipality" not in ancestors,
        "formatted_address": geo["formatted_address"],
    }

A call to get_jurisdiction("4872 Old Mill Road, Springfield, OH 45502") returns a flat dict your permit-routing table can query directly.

Wiring jurisdiction into a permitting intake workflow

The jurisdiction resolution above tells you *where* the address sits in the administrative hierarchy. Mapping that to "which permit desk" and "which fee schedule" is your business logic — and it belongs in your system, not in the API. Here is the pattern.

Step 1: Maintain a jurisdiction routing table

Keep a table (database, config file, or CMS) that maps (state, county, municipality) tuples to permit office metadata: contact URL, accepted submission formats, typical turnaround days, and fee schedule identifier. For unincorporated areas the key is (state, county, null).

PERMIT_OFFICES = {
    ("Ohio", "Clark County", "Springfield"):       {"office": "City of Springfield Planning",   "url": "https://..."},
    ("Ohio", "Clark County", None):                {"office": "Clark County Building Dept",      "url": "https://..."},
    ("Ohio", "Franklin County", "Columbus"):       {"office": "Columbus Development Services",   "url": "https://..."},
    # ...
}

def route_permit(jurisdiction: dict) -> dict | None:
    key = (
        jurisdiction["state"],
        jurisdiction["county"],
        None if jurisdiction["is_unincorporated"] else jurisdiction["municipality"],
    )
    return PERMIT_OFFICES.get(key)

This separation — API resolves the hierarchy, your code resolves the routing — is the right seam. When a county redistricts or a city annexes new territory, you update one row in your routing table. You do not wait for an API vendor to catch up.

Step 2: Flag jurisdiction ambiguity before submission

Two situations warrant a manual review flag before any permit application goes out:

  1. Low geocoding confidence (below 0.75). The address may have been matched to the wrong parcel. A permit submitted with a bad lat/lng may land in entirely the wrong jurisdiction — an easy mistake to make on new subdivisions where the address database has not caught up with construction.
  2. No matching routing rule. If route_permit() returns None, the jurisdiction combination is one you have not seen before. That is the signal to add a row to your routing table, not to silently drop the application.

A third situation worth checking — one the API supports — is when the geocoded coordinate falls very close to a boundary between two jurisdictions. A parcel 15 metres inside a city boundary from the county line may have had its boundary shift in a recent annexation that the address database has not reflected. If this is a real concern for your project types, query the Boundaries endpoint for the four corners of the parcel footprint (if you have that data) and verify that all four return the same municipality. A mismatch is the signal to escalate.

Step 3: Cache jurisdiction lookups aggressively

Addresses do not move. Administrative boundaries change on a slow political cadence — typically a few times per decade per jurisdiction, announced months in advance. A jurisdiction lookup cached for 90 days is almost certainly still correct. One cached for a year is correct for 99% of addresses.

Cache by the rounded coordinate (4 decimal places ≈ 11 metres of precision — more than enough for jurisdiction lookup) and by the normalised address string. On a construction firm processing 200 permit applications per month, a 90-day TTL reduces live API calls by 60-80% as projects in the same neighbourhoods repeat. The Caching Geocoding Results — 90% Cost Reduction post covers the Redis and in-process caching patterns that work directly here.

Step 4: Handle the unincorporated area case explicitly

The absence of a municipality in the ancestors array is the single most common source of permit-routing errors. When is_unincorporated is True, the permit office is the county — but which county desk? Many counties divide planning, building, and fire-inspection responsibilities across separate departments. Your routing table should carry the specific department URL for each permit type, not just the county name.

Build the UI to surface this clearly. When a contractor submits an address and the system returns is_unincorporated: true, show a message that reads: "This address is in unincorporated Clark County. Permits go to the Clark County Building Department, not Springfield City Hall." Most contractors know their jurisdiction once told; the problem is the systems that silently assume "Springfield" means the city.

Step 5: Record the jurisdiction at intake, not at submission

The time to resolve jurisdiction is when the project record is created, not when the permit application is assembled three weeks later. By the time the application is ready, a dozen decisions — which inspector to schedule, which code edition governs, which fee estimate to give the client — have already been made on whatever jurisdiction the project manager assumed. If that assumption was wrong, you are not correcting one field; you are unwinding a thread of downstream decisions.

Wire the jurisdiction lookup into your project-creation form. As soon as an address is entered and verified, resolve and store state, county, municipality, and is_unincorporated. Surface the routing result ("Permits: Clark County Building Dept") in the project summary view, not buried in a metadata panel. Make it impossible to miss.

The multi-jurisdiction edge case: projects that span boundaries

A large commercial site — warehouse, data centre, solar farm — may span a county or municipal boundary. The permit implications depend on jurisdiction: in most states, each portion of the project within a given jurisdiction requires permits from that jurisdiction's authority. A project that crosses a line needs two sets of applications, two inspectors, and potentially two conflicting code editions.

The Boundaries endpoint gives you a point answer, not a polygon answer. If you have the parcel footprint as a set of coordinates, the right approach is to query the Boundaries endpoint for the four corners of the bounding box plus the centroid and compare. If all five queries return the same hierarchy, you have a single-jurisdiction project with high confidence. If any corner returns a different county or municipality, you have a boundary-straddling project and need legal confirmation of which portions fall where.

def check_multi_jurisdiction(corners: list[tuple[float, float]]) -> bool:
    """Returns True if project footprint appears to span jurisdictions."""
    hierarchies = []
    for lat, lng in corners:
        r = requests.get(
            f"{API}/boundaries",
            params={"lat": lat, "lng": lng, "api_key": KEY},
            timeout=15,
        )
        r.raise_for_status()
        ancestors = {a["level"]: a["name"] for a in r.json().get("ancestors", [])}
        hierarchies.append((ancestors.get("county"), ancestors.get("municipality")))
    return len(set(hierarchies)) > 1

If check_multi_jurisdiction returns True, the project record gets a multi_jurisdiction: true flag and a required manual-review step before any permit routing proceeds.

What you are not getting from this API

Honesty about scope matters. Three things the Boundaries endpoint does not do:

Zoning districts. Administrative hierarchy (county, municipality) and zoning district (R-1, B-2, I-Light) are different things. Zoning districts are sub-municipal polygons defined and maintained by each local planning department. They are not returned by the CSV2GEO API. To resolve zoning, you need either the municipality's own parcel data API (most large cities now publish one), a commercial parcel-data feed, or a manual lookup in the planning department's GIS. The jurisdiction resolution from CSV2GEO tells you which planning department to query; the zoning data comes from that planning department.

Parcel boundaries. CSV2GEO returns administrative hierarchy for a point, not the legal parcel polygon. For parcel boundaries — setback calculations, lot-coverage limits, easement locations — you need the county assessor's parcel data. In most US counties this is published as a GIS download or an ArcGIS REST service.

Special districts. School districts, fire districts, utility districts, and special assessment districts are not necessarily coextensive with the county/municipality hierarchy. The Boundaries endpoint returns the standard administrative levels; it does not enumerate every special-purpose district that may overlay a parcel. For fire-inspection jurisdiction specifically — relevant for commercial permitting — verify with the county assessor or call the county directly.

These gaps are not surprising once you understand what the API is actually doing: it is resolving the place in the administrative hierarchy of governed territory. That is exactly what you need for permit routing. Everything below the municipality level is local data that you have to source locally.

Cost and rate considerations

At 461 million addresses across 39 countries, the underlying dataset is large enough that you will not commonly hit coverage gaps in populated areas. The free tier provides 3,000 calls per day — enough for a small construction firm running 50-100 permit lookups per day with room to spare for testing. Paid tiers start at $54/month for 100,000 calls; the full schedule is at csv2geo.com/pricing/api.

Each permit application resolution uses two credits: one geocoding call and one Boundaries call. At the entry paid tier that is under $0.001 per project. Cache aggressively and the effective cost drops further.

For batch enrichment of an existing project database — a firm migrating from a manual tracking spreadsheet to a proper permitting system — run the geocoding in batches and the Boundaries lookups in a controlled loop with exponential backoff on rate-limit responses. The pattern is documented in detail in Exponential Backoff — When to Retry, When to Stop. On a 10,000-project import, budget 20,000 credits and a few hours of wall-clock time at a conservative concurrency of 10 parallel requests.

SDKs for Python and Node are available and work against the same endpoints shown here. For production pipelines, most teams wrap the REST endpoints in their own thin client — the two calls above are simple enough that a home-grown wrapper is more maintainable than a pinned SDK version, and it keeps your error handling exactly where you can see it.

The honest accuracy question

Two questions about accuracy that come up in every permitting integration discussion.

How current are the administrative boundaries? The boundaries data is updated on a regular schedule, not real-time. Recent annexations — a municipality voting to incorporate new territory — may not appear immediately. For most permitting workflows the lag is immaterial because annexed territory typically takes months to move through the planning and code adoption process anyway; by the time a contractor is pulling permits for a newly-annexed parcel, the boundaries data is almost certainly current. For a jurisdiction that has been very recently incorporated or dissolved, verify against the county recorder before submitting.

What if the geocode places the address on the wrong side of a boundary? For a parcel that sits 20 metres inside a municipal boundary, a geocoding offset of 30 metres could place the lat/lng in the wrong jurisdiction. This is rare — geocoding accuracy in populated areas is typically within 10-15 metres — but it is not impossible on new subdivisions or rural roads. The mitigation is the confidence-score threshold (reject low-confidence results for manual review) and, for high-stakes projects, the four-corners check described above. A geocoding accuracy deep-dive lives at Reverse Geocoding Accuracy — Distance in Meters.

Frequently Asked Questions

What is the difference between a mailing city and the governing municipality? A mailing city is what the postal service assigned to a delivery route. The governing municipality is the incorporated body that has legal authority over land use, building codes, and permitting. They often differ — particularly in fast-growing suburbs, rural areas with large unincorporated zones, and enclaves. Always resolve jurisdiction from coordinates against administrative boundaries, not from the city field in a mailing address.

Does the Boundaries endpoint work outside the United States? Yes. CSV2GEO covers 39 countries. The ancestors array returns administrative levels consistent with each country's hierarchy — state/province equivalents, county equivalents, and municipality equivalents all appear under standardised level values so your application code does not need country-specific branching. Coverage depth varies by country; the US, UK, and most of Western Europe are covered to municipality level.

Can I use this to determine which building code edition applies? Indirectly. The building code edition is a function of which jurisdiction governs the parcel — and the jurisdiction is what the Boundaries endpoint tells you. Mapping jurisdiction to code edition (IBC 2021 with local amendments, local residential code, etc.) is your business logic; maintain that mapping in your own permitting rules table and look it up using the jurisdiction fields the API returns.

What if the Boundaries endpoint returns no municipality — just a county? That means the address is in an unincorporated area. The county planning or building department has jurisdiction, not a city or town hall. Your permit-routing table should have an explicit row for (state, county, null) that points to the county department. Unincorporated areas are common in rural markets and in suburban counties where annexation has been slow — do not treat the absence of a municipality as an error.

How should I handle addresses near the edge of a boundary, where the margin for error matters? Query the Boundaries endpoint for the address coordinate and for two or three coordinates offset by 20-30 metres in each direction. If all queries return the same hierarchy, you have high confidence in the jurisdiction. If any query returns a different county or municipality, flag the project for manual confirmation before submitting any permit application.

Is there a way to get the jurisdiction for a batch of addresses at once? The geocoding endpoint processes one address per call. Batch the calls in a controlled concurrent loop — 10 to 20 parallel requests is typically well within rate limits — and then batch the resulting coordinates into Boundaries calls. Each Boundaries call is also one coordinate, so the total credit cost is 2 credits per address. For large imports, cache results by coordinate to avoid re-processing addresses you have already resolved.

Does CSV2GEO provide the actual permit application forms or fee schedules? No. CSV2GEO resolves administrative hierarchy. Permit forms, fee schedules, and submission requirements are maintained by each individual jurisdiction. The API tells you which desk to go to; the desk's own website tells you what to bring. This is intentional — permit rules change frequently, and any API that claimed to encode them centrally would be stale within months.

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 →