Replacing flood zone PDFs with a 50 ms API call

Replace manual FIRM PDF lookups with a real-time elevation + boundaries API call per policy address. Patterns, code, failure modes, and cost math.

| June 11, 2026
Replacing flood zone PDFs with a 50 ms API call

The Flood Insurance Rate Map — a FIRM — is a regulatory artifact. It is also, for most underwriting teams, a genuine operational bottleneck. An analyst opens a browser, types an address into a lookup portal, downloads a PDF panel, finds the right panel among fifteen that overlap the property, reads the zone designation, and transcribes it into the policy system. On a good day that is three minutes per address. On a day when the address spans a panel boundary, or the community number is ambiguous, or the portal is slow, it takes longer.

At a few policies a day that is fine. At five thousand renewals a week it is a full-time job that produces transcription errors. At fifty thousand renewals it is a staffing problem, a data-quality problem, and a compliance problem.

The pattern this post shows is not a workaround. It is a proper engineering solution: query elevation and administrative boundary data per policy address via REST, derive the first-order terrain signal programmatically, and surface the right inputs to the regulatory lookup instead of doing the regulatory lookup manually. The last step — the actual FEMA zone classification — remains a regulatory call. But the mechanical retrieval of coordinates, elevation, county, community number, and flood-risk tier is fully automatable today, and it should be.

What you are actually doing when you look up a flood zone

Flood zone classification has several inputs. Unpacking them makes the automation obvious.

Address → coordinates. The FIRM system is coordinate-based. You cannot look up a flood zone without knowing where the property actually is. Every manual workflow starts here, even if it is implicit.

Coordinates → elevation. The zone boundary is, at its core, a contour. Properties above a certain elevation are typically outside the high-risk Special Flood Hazard Area; properties below it are inside. The exact contour is determined by a regulatory hydraulic study, not just topography, but elevation is the dominant input. A property at −5 m is not in the same risk bucket as a property at +25 m regardless of what the map says.

Coordinates → administrative boundary. FIRM panels are issued per community, and "community" in the FEMA sense is a specific administrative unit with a NFIP community number. You need to know which county, which incorporated municipality, and in some markets which special flood hazard district the property sits in. That is a boundary lookup.

Boundary → NFIP community number → panel number → zone. This last step is genuinely regulatory — it crosses into official FEMA data and official zone designations. You must do this step correctly and attribute it correctly. But all the steps before it are just data retrieval, and they are the steps that kill analyst time.

The two CSV2GEO endpoints relevant here are /api/v1/elevation and /api/v1/boundaries. Together they give you coordinates, terrain height, county, municipality, state, and any additional administrative overlays for the address — in a single API session, in well under a second. Feed those outputs into your FEMA lookup (or your existing zone-classification vendor) and the manual work collapses to exception handling for genuine edge cases.

The two endpoints

Elevation — the terrain anchor

GET /api/v1/elevation takes one to 500 coordinate pairs per request and returns a height in metres above sea level for each. The geometry is global. Coverage is real: Mauna Kea's summit returns 4,198 m, Denver returns 1,597 m, Miami returns 1 m, the Dead Sea shore returns −415 m, Death Valley's Badwater Basin returns −80 m. Negative numbers are correct, not clamped-to-zero artefacts — this matters enormously for coastal and below-sea-level properties where the entire flood narrative lives in the sign.

The response shape for a single point:

{
  "meta": {"count": 1},
  "results": [
    {"lat": 29.9511, "lng": -90.0715, "elevation_m": -1}
  ]
}

That −1 m is a New Orleans address. It is the number that should appear next to the policy before anyone looks at a FIRM panel.

Boundaries — the administrative anchor

GET /api/v1/boundaries takes a coordinate pair and returns the administrative hierarchy: country, state/region, county, municipality, and any overlapping special districts or postal geographies that intersect the point. For US flood-zone work the fields you care about most are county_fips (the five-digit FIPS code that maps directly to NFIP community numbers) and municipality (which tells you whether the property is in an incorporated place with its own NFIP community record, or in the unincorporated county).

curl -G "https://csv2geo.com/api/v1/boundaries" \
  --data-urlencode "lat=29.9511" \
  --data-urlencode "lng=-90.0715" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

Returns something like:

{
  "country": "US",
  "state": "Louisiana",
  "state_code": "LA",
  "county": "Orleans Parish",
  "county_fips": "22071",
  "municipality": "New Orleans",
  "postal_code": "70116"
}

The county_fips value 22071 is your entry point to the NFIP community lookup table. You now know which community's FIRM panels to pull. That step — which takes an analyst two minutes when done manually — takes the API under a hundred milliseconds.

Wiring it together: a complete policy enrichment pipeline

The following Python script takes a CSV of policy addresses, geocodes each one, calls elevation and boundaries in parallel, and writes an enriched output row per policy. It is the skeleton of a real underwriting enrichment job — not a demo, not a toy.

import csv
import os
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed

API_BASE = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_API_KEY"]
SESSION = requests.Session()
SESSION.params = {"api_key": KEY}

def geocode(address):
    r = SESSION.get(f"{API_BASE}/geocode",
                    params={"q": address}, timeout=15)
    r.raise_for_status()
    results = r.json().get("results", [])
    if not results:
        return None, None, None
    top = results[0]
    return top["lat"], top["lng"], top.get("confidence", 0)

def get_elevation(lat, lng):
    r = SESSION.get(f"{API_BASE}/elevation",
                    params={"points": f"{lat},{lng}"}, timeout=15)
    r.raise_for_status()
    items = r.json().get("results", [])
    return items[0].get("elevation_m") if items else None

def get_boundaries(lat, lng):
    r = SESSION.get(f"{API_BASE}/boundaries",
                    params={"lat": lat, "lng": lng}, timeout=15)
    r.raise_for_status()
    return r.json()

def enrich_policy(row):
    lat, lng, conf = geocode(row["address"])
    if lat is None:
        return {**row, "lat": None, "lng": None, "confidence": None,
                "elevation_m": None, "county_fips": None,
                "municipality": None, "risk_tier": "geocode_failed"}
    ele = get_elevation(lat, lng)
    bounds = get_boundaries(lat, lng)
    tier = risk_tier(ele)
    return {
        **row,
        "lat": lat,
        "lng": lng,
        "confidence": conf,
        "elevation_m": ele,
        "county_fips": bounds.get("county_fips"),
        "municipality": bounds.get("municipality"),
        "state_code": bounds.get("state_code"),
        "risk_tier": tier,
    }

def risk_tier(ele_m):
    if ele_m is None:
        return "unknown"
    if ele_m < 0:
        return "below_sea_level"    # immediate FIRM review required
    if ele_m < 3:
        return "coastal_low"        # high-priority review
    if ele_m < 10:
        return "low_elevation"      # standard flood review
    if ele_m > 500:
        return "high_terrain"       # minimal flood risk, wind/snow review
    return "standard"

with open("policies.csv") as fin, \
     open("policies_enriched.csv", "w", newline="") as fout:
    reader = csv.DictReader(fin)
    extra = ["lat", "lng", "confidence", "elevation_m",
             "county_fips", "municipality", "state_code", "risk_tier"]
    writer = csv.DictWriter(fout, fieldnames=reader.fieldnames + extra)
    writer.writeheader()
    rows = list(reader)
    with ThreadPoolExecutor(max_workers=8) as pool:
        futures = {pool.submit(enrich_policy, row): row for row in rows}
        for fut in as_completed(futures):
            writer.writerow(fut.result())

A few notes on the design choices:

  • `ThreadPoolExecutor(max_workers=8)` is a conservative concurrency setting for a paid API plan. See Concurrency Tuning for Geocoding Pipelines for how to find the right number for your specific tier.
  • Elevation and boundaries are called sequentially per policy in this script because the dependency chain is geocode → (elevation, boundaries). In a higher-throughput version you would fire elevation and boundaries in parallel after geocoding, which cuts latency-per-policy roughly in half. The structure above is clearer for a first implementation.
  • The risk_tier function is intentionally simple. Replace the thresholds with your actuarial team's preferred breakpoints. The point is the pattern — a programmatically derived tier that routes the policy to the right queue before a human ever opens a FIRM PDF.

The same pattern in Node

For teams running Node on the enrichment job:

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

async function enrichPolicy(address) {
  // Geocode
  const geoUrl = `${API}/geocode?q=${encodeURIComponent(address)}&api_key=${KEY}`;
  const geoRes = await fetch(geoUrl);
  if (!geoRes.ok) throw new Error(`geocode http ${geoRes.status}`);
  const geoJson = await geoRes.json();
  const top = geoJson.results?.[0];
  if (!top) return { address, error: 'no_geocode_result' };

  const { lat, lng } = top;

  // Elevation + Boundaries in parallel
  const [eleRes, bndRes] = await Promise.all([
    fetch(`${API}/elevation?points=${lat},${lng}&api_key=${KEY}`),
    fetch(`${API}/boundaries?lat=${lat}&lng=${lng}&api_key=${KEY}`),
  ]);

  if (!eleRes.ok) throw new Error(`elevation http ${eleRes.status}`);
  if (!bndRes.ok) throw new Error(`boundaries http ${bndRes.status}`);

  const eleJson = await eleRes.json();
  const bndJson = await bndRes.json();

  const elevationM = eleJson.results?.[0]?.elevation_m ?? null;

  return {
    address, lat, lng,
    confidence: top.confidence,
    elevation_m: elevationM,
    county_fips: bndJson.county_fips,
    municipality: bndJson.municipality,
    state_code: bndJson.state_code,
    risk_tier: riskTier(elevationM),
  };
}

function riskTier(eleM) {
  if (eleM === null || eleM === undefined) return 'unknown';
  if (eleM < 0)   return 'below_sea_level';
  if (eleM < 3)   return 'coastal_low';
  if (eleM < 10)  return 'low_elevation';
  if (eleM > 500) return 'high_terrain';
  return 'standard';
}

The Promise.all on the elevation and boundaries calls is the key difference from the Python version — Node's concurrency model makes the parallel structure natural. Both calls hit the same API key, the same rate-limit bucket, and the same response latency characteristics. The total round-trips per policy drop from three sequential to two parallel plus one sequential, which matters when you are processing thousands in a batch.

Batching elevation at scale

The Python and Node examples above call /api/v1/elevation once per policy, which is fine for a handful of policies but wasteful at scale. The endpoint accepts up to 500 coordinate pairs per request. For a 10,000-policy renewal book:

def batch_elevations(coord_list):
    """coord_list: list of (lat, lng) tuples, any length."""
    results = []
    for i in range(0, len(coord_list), 500):
        batch = coord_list[i:i+500]
        points = "|".join(f"{lat},{lng}" for lat, lng in batch)
        r = SESSION.get(f"{API_BASE}/elevation",
                        params={"points": points}, timeout=30)
        r.raise_for_status()
        results.extend(r.json()["results"])
    return results  # same order as input

Ten thousand policies become 20 elevation API calls. At a free-tier allowance of 3,000 calls per day, you can enrich roughly 1.4 million elevation points per day for zero cost. The paid tier starting at $54/month for 100,000 calls handles a mid-market carrier's annual renewal book in a few minutes of wall-clock time.

Boundaries cannot be batched the same way — each call is per coordinate — but boundary data changes far less frequently than your policy book. A county FIPS code for a property in Orleans Parish will be 22071 next year and the year after. Cache the boundary response aggressively; see Caching Geocoding Results — 90% Cost Reduction for the Redis pattern that reduces repeat boundary lookups to near zero cost on renewal cycles.

Failure modes to handle before you ship

Three failure modes that will bite you in production if you do not plan for them.

Ambiguous geocode confidence. When /api/v1/geocode returns a confidence score below 0.7, the coordinate is uncertain enough that downstream elevation and boundary data may be for the wrong parcel. In an underwriting context this is worse than a missing row — a wrong elevation at high confidence will route a policy to the wrong review queue without anyone noticing. Branch explicitly on confidence: below 0.7, flag the policy for manual address verification before enrichment. Do not silently enrich with a low-confidence coordinate.

Null elevation near water. Points over open water return "elevation_m": null. This is correct behaviour — there is no terrain at a coordinate in the middle of a bay. Coastal policies where the geocoder snaps to a pier or a marina address can hit this. Your risk_tier function must handle null explicitly; the Python and Node examples above both do. Do not treat null elevation as 0 m elevation — a pier at sea level and a property at 0 m elevation are genuinely different things for flood underwriting.

Below-sea-level properties. Negative elevation values are correct. Parts of New Orleans, the Sacramento-San Joaquin Delta, and coastal Louisiana return negative numbers. If your code has a floor check that clips negative elevations to zero, remove it. The negative sign is the most important bit of information on those policies.

How the enriched output fits into your existing FEMA lookup

To be precise about scope: this pipeline does not replace the FEMA FIRM lookup. It replaces the manual data-retrieval steps that precede it.

After running this enrichment, each policy row has:

  • A verified lat/lng with a confidence score
  • An elevation in metres
  • A county FIPS code and municipality name
  • A programmatic risk tier

Your FEMA lookup — whether that is a homegrown integration with the MSC API, a licensed zone-determination vendor, or a manual analyst workflow — now receives a clean, structured input rather than a free-text address. The analyst who previously spent three minutes on address lookup and panel identification now spends thirty seconds reviewing an already-classified row and either confirming or overriding the tier.

The compounding effect over a large renewal book is significant. At five thousand renewals per week, moving from three minutes per address to thirty seconds per exception (assume 15% exception rate) saves roughly 220 analyst hours per week. At a fully-loaded cost of £50/hour for a trained analyst, that is £11,000 per week. The API cost at $54/month is rounding error.

HowTo: end-to-end flood enrichment in five steps

Step 1: Geocode the policy address and check confidence

Call /api/v1/geocode with the policy address. Check the confidence field on the top result. If confidence is below 0.7, write the policy to an exceptions file for manual address verification. Do not proceed with enrichment until the address is verified. This step is not optional — a confident wrong answer is worse than a known gap.

Step 2: Batch elevation lookups across the policy book

Group the geocoded coordinates into batches of up to 500. Call /api/v1/elevation once per batch. Map the responses back to policies by array index — the response order is guaranteed to match the input order. Store elevation_m on the policy row. Note null values as a separate flag, not as zero.

Step 3: Call boundaries per policy coordinate

Call /api/v1/boundaries with the lat/lng for each policy. Extract county_fips, municipality, and state_code. Cache the response — these values are stable for years and do not need to be re-queried on every renewal cycle. For a national US book, a Redis key of boundaries:{lat_rounded_4dp}:{lng_rounded_4dp} with a 90-day TTL eliminates essentially all repeat boundary calls on annual renewals.

Step 4: Derive and assign a risk tier

Apply your risk_tier function to the elevation_m value for each policy. The exact breakpoints belong to your actuarial team; the pattern is a function that maps a float (or null) to a string label that routes the policy to the correct review queue. Below-sea-level policies go to immediate FIRM review. Low-elevation coastal policies go to standard flood review. High-terrain policies go to wind and snow review. Standard policies proceed through normal underwriting.

Step 5: Feed county FIPS and municipality into your zone determination step

Pass county_fips and municipality from the enriched row to your FEMA MSC lookup, your zone-determination vendor, or your analyst's review queue. The analyst — or the automated lookup — no longer needs to derive these from a raw address. The panel identification step, which is where most manual lookup time is spent, is now pre-populated. Exception handling from this point forward is genuinely exceptional: panel-boundary straddling, community numbering disputes, properties under active map revision.

Cost model for a mid-market carrier

A carrier with 200,000 policies in force, running annual renewals and processing new business at 2,000 policies per month:

| Operation | Volume/year | Calls | Credits | |---|---|---|---| | Geocode (new business + renewals) | 224,000 | 224,000 | 224,000 | | Elevation (batched at 500) | 224,000 | 448 | 224,000 | | Boundaries (cached, 10% cache miss) | 224,000 | 22,400 | 22,400 | | Total | | | 470,400 |

At pricing starting from $54/month for 100,000 calls, the annual credit spend for a carrier of this size is comfortably within two to three paid tiers, depending on the bracket. The full pricing breakdown is at csv2geo.com/pricing/api. There is no per-seat fee, no minimum commit, and no quote process — the numbers on the page are the numbers.

For a carrier piloting the integration on a subset of the renewal book, the free tier (3,000 calls per day, no credit card) covers 1.5 million elevation points per day and roughly the same volume of geocoding and boundary calls. A pilot on 5,000 policies runs in two days at zero cost.

What the enriched output cannot tell you

The honest constraint list belongs in every post about flood-adjacent data.

`elevation_m` is not a zone classification. It is a first-order terrain signal that correlates with zone classification but is not identical to it. An address at +2 m in a well-engineered coastal community with maintained levees may be in Zone X; an address at +8 m near a poorly-maintained creek may be in Zone AE. The regulatory determination requires the official FIRM lookup. Use elevation to triage and route; use the FIRM lookup to classify.

The DEM samples terrain, not structures. A property at +3 m with a 2 m crawl space and a finished basement is a different flood product from a slab-on-grade property at the same elevation. First-floor elevation, which is the number that actually determines flood insurance premium under the NFIP, requires a survey or an elevation certificate. The API gives you the terrain; the elevation certificate gives you the building offset.

Boundary data reflects current administrative boundaries. Communities that have been annexed, de-annexed, or reorganised since the last boundary dataset update may return a municipality that does not match the NFIP community record. For properties in growth-edge suburban areas, always verify the community number against the official NFIP community list before binding.

Frequently Asked Questions

Can I use elevation as a direct substitute for a FIRM zone determination?

No. Elevation is an input to zone determination, not the output. Use it to triage policies, route them to the right review queue, and pre-populate the inputs to your FEMA MSC or zone-determination vendor lookup. The official zone classification is a regulatory determination that must come from the FIRM.

What elevation do you return for a property in New Orleans?

The API returns the actual terrain elevation, which for much of New Orleans is negative — typically −1 m to −3 m depending on the neighbourhood. This is correct. If your code clips negative values to zero, you will produce wrong output for some of the highest-risk policies in your book.

How do I handle addresses that span a county line?

The boundaries endpoint returns the administrative unit for the exact coordinate the geocoder resolves. If a large parcel genuinely straddles a county line, the returned county_fips reflects whichever side of the line the address point lands on — which is typically the lot centroid. For properties where this matters (large rural parcels, industrial sites), flag for manual review rather than relying on the API-derived FIPS code alone.

Is the free tier sufficient for a proof-of-concept on our renewal book?

3,000 calls per day at the free tier, with elevation batching at 500 points per call, gives you 1.5 million elevation lookups per day for zero cost. A proof-of-concept on 10,000 policies — with geocoding, elevation, and boundaries — runs in two to three days at no cost and no credit card required.

How fresh is the elevation data?

The DEM is updated on a multi-year cycle for most coverage areas. Terrain does not change on the timescales that matter for underwriting — a property's ground elevation is the same this year as it was three years ago to well within the precision relevant for flood triage. The 30 m horizontal resolution means the number is a terrain height for the vicinity of the address, not a surveyed point elevation for the building footprint.

Do the SDKs support the boundaries endpoint?

Python and Node SDKs are available, but the REST interface is intentionally simple and most production teams wrap it in their own thin client rather than taking an SDK dependency. The examples in this post are pure REST — curl, requests, and fetch — with no SDK version to pin or upgrade.

What is the right caching TTL for boundary data?

County FIPS codes and municipality names are stable for years. A 90-day TTL on a Redis key of boundaries:{lat4dp}:{lng4dp} is a reasonable default. You will get near-100% cache hits on annual renewal books for the same policy addresses. See Caching Geocoding Results — 90% Cost Reduction for the full caching pattern.

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 →