Assigning members to the nearest chapter with routing and boundaries

Geocode your member roster, assign by territory boundary or nearest drive time, and flag mismatches — all without a GIS team. Step-by-step REST guide.

| August 23, 2026
Assigning members to the nearest chapter with routing and boundaries

Most associations assign members to chapters the same way they have done it since paper rosters were a thing: by ZIP code or county. That rule is fast to administer and easy to explain, but it breaks the moment a chapter boundary cuts across a postal district — which is nearly every chapter boundary, in every state, for every association that has tried to draw territories that reflect real communities rather than census geometries.

The result is a membership ops problem that sits, unresolved, in a spreadsheet until it becomes a complaints problem. Members who live five minutes from chapter B are assigned to chapter A twenty minutes away, attend less, renew at a lower rate, and occasionally email the national office to ask why they are being sent to a meeting on the wrong side of town. The answer — "that is how the ZIP codes lined up" — is not satisfying.

This post shows how to fix it with three API surfaces: batch geocoding for the roster, territory boundaries for spatial assignment, and a routing matrix for the drive-time nearest-chapter check. The output is a mismatch report that shows every member who is inside territory A but closer by driving to chapter B. The technical work is REST calls and spreadsheet manipulation. No GIS engineer required.

The two assignment models and when each one applies

Before writing any code it is worth being precise about what "assign to the nearest chapter" actually means, because there are two defensible definitions and they produce different answers.

Boundary containment. The chapter territory is a polygon. A member belongs to the chapter whose polygon contains their geocoded address. This is the canonical answer for associations with legally or formally defined territories — unions with jurisdiction districts, bar associations with county court maps, alumni regional clubs with explicit charters. The rule is unambiguous: inside the boundary, you belong here. The problem is that boundaries do not respect road networks. A member 200 metres inside territory A may be 45 minutes by road from the chapter A meeting venue, while the chapter B venue is 10 minutes away via a direct motorway slip road.

Drive-time nearest. The chapter a member is closest to by actual driving time. This is the right model for associations where chapter territories are soft conventions rather than charter obligations — professional networks, civic clubs, hobbyist associations — and where attendance is the thing that matters. A member who is 20 minutes from chapter A but 8 minutes from chapter B will almost always attend chapter B, regardless of what the territory map says.

In practice, most associations need both: the boundary containment answer for official assignment and the drive-time answer for attendance prediction and the mismatch report. Running both and comparing them is the most valuable output.

What the API surfaces give you

Batch geocoding via the web tool. Upload your member roster as a CSV; the web tool geocodes every address and returns latitude and longitude per row. Credits are consumed per address row. This is the right starting point for membership ops teams who do not want to write code — it is a browser upload, a download of the enriched CSV, and a few minutes of wait time for a roster of several thousand.

Boundaries/Divisions endpoint. Given a lat/lng, returns the containing administrative or custom boundary. For territory-based assignment, you can match against chapter boundary polygons. The endpoint also supports point-in-polygon queries so you can ask "which of my named territories contains this point?" without building spatial indexing yourself.

Routing N×M matrix. Given N origins (member addresses) and M destinations (chapter meeting venues), returns an N×M table of drive times. You ask: for each of my members, what is the drive time to every chapter venue? Then you find the minimum per row — that is the nearest chapter by drive time.

Both the boundary and the routing matrix work from lat/lng, which is why geocoding the roster first is non-negotiable. Everything downstream depends on having accurate coordinates per member.

A note on personal data: member addresses are personal information. Once you have the geocoded coordinates for your internal pipeline, follow the minimise-retention pattern — keep the coordinates in your processing environment for as long as the job needs them, then discard them if they are not part of the record of truth in your membership management system. The API supports a no_record mode that tells the server not to log the input address; for member data, use it.

Step 1: Geocode the roster

If your membership team is comfortable with a browser, the web batch tool at csv2geo.com handles this without code. Upload a CSV with an address column, map the fields, download the enriched CSV with lat and lng appended.

For engineering teams building an automated pipeline, the same operation via REST:

curl -s -G "https://csv2geo.com/api/v1/geocode" \
  --data-urlencode "q=742 Evergreen Terrace, Springfield, IL 62701" \
  --data-urlencode "api_key=$CSV2GEO_KEY"

In Python, batching a roster of N addresses with a simple retry wrapper:

import csv
import os
import time
import requests

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

def geocode_member(address, retries=3):
    for attempt in range(retries):
        try:
            r = requests.get(
                API,
                params={"q": address, "api_key": KEY, "no_record": 1},
                timeout=15,
            )
            if r.status_code == 429:
                time.sleep(2 ** attempt)
                continue
            r.raise_for_status()
            results = r.json().get("results", [])
            if results:
                top = results[0]
                return top.get("lat"), top.get("lng"), top.get("confidence")
        except requests.RequestException:
            time.sleep(2 ** attempt)
    return None, None, None

with open("members.csv") as fin, open("members_geocoded.csv", "w", newline="") as fout:
    reader = csv.DictReader(fin)
    writer = csv.DictWriter(fout, fieldnames=reader.fieldnames + ["lat", "lng", "confidence"])
    writer.writeheader()
    for row in reader:
        lat, lng, conf = geocode_member(row["address"])
        row.update({"lat": lat, "lng": lng, "confidence": conf})
        writer.writerow(row)

Two details that matter for membership rosters specifically.

Low-confidence results need a human review queue. Members who moved recently, or whose addresses were entered with typos in the membership system, geocode poorly. Any row where confidence < 0.7 should go into a separate "needs manual review" file rather than flowing into the assignment logic. An incorrectly geocoded address will produce a confidently wrong chapter assignment, which generates a member complaint. See Geocoding Confidence Scores Explained for the full breakdown of what the confidence field means.

Use `no_record=1` for member addresses. Member home addresses are personal data. Passing no_record=1 tells the API not to retain the input string in server logs. This is the minimise-retention pattern; use it whenever you are geocoding personal data rather than commercial address inventories.

Step 2: Assign by boundary containment

With geocoded coordinates per member, the boundary assignment query is straightforward. For each member's lat/lng, query the Boundaries endpoint to find which chapter territory polygon contains the point.

curl -s -G "https://csv2geo.com/api/v1/boundaries" \
  --data-urlencode "lat=41.8827" \
  --data-urlencode "lng=-87.6233" \
  --data-urlencode "layers=chapter_territories" \
  --data-urlencode "api_key=$CSV2GEO_KEY"

In Node, iterating a geocoded roster array:

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

async function getBoundaryAssignment(lat, lng) {
  const url = `${API}?lat=${lat}&lng=${lng}&layers=chapter_territories&api_key=${KEY}`;
  const r = await fetch(url);
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
  const data = await r.json();
  const hit = data.results?.[0];
  return hit ? { chapterId: hit.id, chapterName: hit.name } : { chapterId: null, chapterName: 'unassigned' };
}

// Process roster
const assignments = await Promise.all(
  roster.map(member =>
    getBoundaryAssignment(member.lat, member.lng)
      .then(a => ({ ...member, ...a }))
  )
);

Two edge cases that always appear in real membership rosters.

Members outside all territories. The boundary query returns an empty results array if the point falls outside every defined polygon. This happens with members who live abroad, or in areas that your territory map has not fully covered. These members need a separate handling path — typically "national member, no chapter assignment" or a manual assignment workflow. Do not silently assign them to the nearest boundary; that produces wrong assignments that look correct.

Members on territory borders. A house that sits exactly on a boundary line is ambiguous. The API will return one result (whichever polygon's edge-matching logic wins), but if the confidence indicator is low, flag the row for human review. Border members are often the most motivated to have chapter choice — they are the ones most likely to attend across the boundary anyway.

Step 3: Build the routing matrix for drive-time nearest

The routing matrix takes N member origins and M chapter venues as destinations and returns an N×M table of travel durations. The "nearest chapter" for each member is the column with the minimum value in their row.

For a practical association use case, you typically do not run all members against all chapters — you run each member against the three or four chapters geographically plausible from their location. A simple pre-filter is to restrict each member's candidate set to chapters within 80 km as the crow flies; this keeps the matrix from growing unwieldy.

import requests
import os
import json

API_MATRIX = "https://csv2geo.com/api/v1/matrix"
KEY = os.environ["CSV2GEO_KEY"]

def get_drive_times(origins, destinations):
    """
    origins: list of {"id": str, "lat": float, "lng": float}
    destinations: list of {"id": str, "lat": float, "lng": float}
    Returns: dict keyed by origin_id -> {dest_id: seconds}
    """
    payload = {
        "origins": [{"lat": o["lat"], "lng": o["lng"]} for o in origins],
        "destinations": [{"lat": d["lat"], "lng": d["lng"]} for d in destinations],
        "api_key": KEY,
    }
    r = requests.post(API_MATRIX, json=payload, timeout=60)
    r.raise_for_status()
    raw = r.json()["durations"]  # N x M list of seconds (or null)

    result = {}
    for i, origin in enumerate(origins):
        row = {}
        for j, dest in enumerate(destinations):
            row[dest["id"]] = raw[i][j]  # seconds, or None if route not found
        result[origin["id"]] = row
    return result

def nearest_chapter(drive_times_row):
    """From a dict of {chapter_id: seconds}, return the chapter_id with minimum time."""
    valid = {k: v for k, v in drive_times_row.items() if v is not None}
    if not valid:
        return None
    return min(valid, key=valid.get)

The matrix endpoint is the right tool here rather than issuing N individual route queries. A roster of 2,000 members against 12 chapter venues is a 2,000×12 matrix — one API call returns all 24,000 cells. Issuing 2,000 individual routing queries would cost the same credits but use 2,000 times the network round-trips and run proportionally slower. Batch where you can.

The same pattern in Node for teams running the pipeline as a serverless function:

const API_MATRIX = 'https://csv2geo.com/api/v1/matrix';
const KEY = process.env.CSV2GEO_KEY;

async function getDriveTimes(origins, destinations) {
  const r = await fetch(API_MATRIX, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      origins: origins.map(o => ({ lat: o.lat, lng: o.lng })),
      destinations: destinations.map(d => ({ lat: d.lat, lng: d.lng })),
      api_key: KEY,
    }),
  });
  if (!r.ok) throw new Error(`HTTP ${r.status}`);
  const data = await r.json();
  return data.durations; // N x M array of seconds (or null)
}

One production detail: null in a matrix cell means the routing engine could not find a valid road route between those two points — an island without a bridge, a gated area, or a mapping gap. Treat null as infinite (exclude from the minimum), not as zero. A member on a ferry-access island has no chapter assignment by drive time and should fall back to the boundary containment answer or a manual assignment.

Step 4: Produce the mismatch report

The mismatch report is the product of the two previous steps. For each member, you now have two answers:

  • boundary_chapter: the chapter whose territory polygon contains the member's address
  • nearest_chapter: the chapter with the shortest drive time from the member's address

Rows where these agree are straightforward. Rows where they disagree are the mismatch candidates. Not all disagreements are worth acting on — a member who is 25 minutes from chapter A (assigned) and 24 minutes from chapter B (nearest) is not a meaningful mismatch. A member who is 45 minutes from chapter A but 8 minutes from chapter B is.

import csv

MISMATCH_THRESHOLD_SECONDS = 900  # flag if nearest is more than 15 min faster

def build_mismatch_report(members, boundary_assignments, drive_times, chapter_venues):
    report = []
    for m in members:
        mid = m["member_id"]
        b_chapter = boundary_assignments.get(mid, {}).get("chapterName", "unassigned")
        times = drive_times.get(mid, {})

        # Time to the boundary-assigned chapter
        assigned_chapter_id = boundary_assignments.get(mid, {}).get("chapterId")
        assigned_time = times.get(assigned_chapter_id) if assigned_chapter_id else None

        # Nearest chapter by drive time
        valid_times = {k: v for k, v in times.items() if v is not None}
        if not valid_times:
            continue
        nearest_id = min(valid_times, key=valid_times.get)
        nearest_time = valid_times[nearest_id]
        nearest_name = chapter_venues[nearest_id]["name"]

        is_mismatch = (
            nearest_id != assigned_chapter_id
            and assigned_time is not None
            and (assigned_time - nearest_time) > MISMATCH_THRESHOLD_SECONDS
        )

        if is_mismatch:
            report.append({
                "member_id": mid,
                "member_name": m["name"],
                "boundary_chapter": b_chapter,
                "nearest_chapter": nearest_name,
                "assigned_drive_min": round(assigned_time / 60, 1),
                "nearest_drive_min": round(nearest_time / 60, 1),
                "time_saved_min": round((assigned_time - nearest_time) / 60, 1),
            })

    report.sort(key=lambda x: x["time_saved_min"], reverse=True)
    return report

Sort by time_saved_min descending. The members at the top of the list are the ones with the worst mismatch — these are the most likely to be low-attenders or non-renewers due to chapter distance. They are also the easiest wins for a membership ops team that wants to improve retention numbers: reach out, offer a chapter transfer, and track whether attendance and renewal improve.

The threshold of 15 minutes is a starting point. Different association types will have different tolerances — a regional professional network whose members drive to monthly evening meetings has a different acceptable threshold from a youth sports association where parent volunteers need to be within 10 minutes.

Step 5: Operationalise the pipeline

A one-off mismatch report is useful. A pipeline that runs quarterly and flags new mismatches as they accumulate — members who moved, new chapter venues, redrawn territories — is more useful.

The simplest operational setup:

  1. Trigger: quarterly cron job, or on-demand via a membership ops dashboard button.
  2. Input: fresh export from the membership management system (whatever CRM or database the association uses — the pipeline does not need a direct integration; export/import is the expected workflow).
  3. Processing: geocode new addresses (skip rows where lat/lng is already cached), run boundary containment, run matrix, produce mismatch report.
  4. Output: a CSV emailed to the membership director, plus a count metric pushed to whatever ops monitoring the team uses.

For observability of the geocoding and routing calls specifically, see Observability for Geocoding Pipelines — the same patterns apply here: log call counts, confidence distributions, null-route rates, and wall-clock time per phase.

Rate limiting is worth thinking about for larger rosters. A national association with 50,000 members running the geocoding phase against the free tier's 3,000 calls per day would take over two weeks. At paid tiers starting at $54/month for 100,000 calls, the same roster geocodes in one run. See Rate Limiting — Token Bucket vs Leaky Bucket for the backoff pattern that keeps bulk geocoding jobs from tripping rate limits mid-run.

Cache geocoded coordinates. A member's address does not change every quarter. Storing (member_id, lat, lng, geocoded_at) in a local database or spreadsheet means the geocoding phase on the second and subsequent runs only processes new members and members who updated their address. This is the single biggest cost reduction available for recurring pipelines — see Caching Geocoding Results — 90% Cost Reduction for the full pattern.

What this analysis does not do

Being explicit about scope prevents the over-engineering trap.

This pipeline produces a mismatch report and a proposed assignment. It does not automatically move members between chapters. The actual reassignment is an organisational decision — it may require bylaws approval, a member communication, a data update in the CRM, and sign-off from the chapter leadership on both sides. Build the report; let the humans make the call.

It also does not model member preference. Some members are assigned to a chapter 30 minutes away and attend faithfully because their profession, their social connections, or the specific programming of that chapter is worth the drive. Drive time is the best proxy for friction, but it is a proxy. The mismatch report surfaces candidates for outreach, not a definitive list of "fix these."

Finally, the routing matrix returns drive times based on road network data. It does not know about members who take public transport, members who cannot drive, or members who regularly travel through an area on a commute. For associations with significant non-driving membership, consider running the matrix twice — once for driving mode and once for transit — and using whichever is more relevant per member segment.

Frequently Asked Questions

Can we do this without writing any code?

For the geocoding step, yes — the web batch tool handles roster uploads through a browser. For the boundary and routing matrix steps, you currently need either REST calls or a scripted workflow. If your team is spreadsheet-heavy and not comfortable with REST, the most practical path is to geocode via the web tool, then hand the lat/lng CSV to whoever in the organisation writes the occasional Python script. The matrix and boundary calls are simple enough that a non-specialist engineer can write them in an afternoon.

How many members can the matrix handle in a single call?

The routing matrix scales to practical association sizes. For very large rosters (tens of thousands of members), process in batches — groups of several hundred members at a time against all chapter venues — rather than one enormous matrix. The Python and Node examples above work correctly in a batched loop. See Dispatch Console — 5,000 Stops per Day for a similar batching pattern at scale.

What if a chapter does not have a fixed venue address?

Use the geographic centroid of the chapter territory as the destination point in the matrix. It is an approximation, but it is a reasonable one for the "which chapter is closest" question. If chapters have a primary meeting venue (a hotel, a union hall, a university building), use that address instead — it is a more honest representation of where members actually need to travel to.

How much does a quarterly run cost for a typical association?

A 5,000-member association with 20 chapters runs approximately: 5,000 geocoding credits (first run; subsequent runs only process new or updated addresses), 5,000 boundary calls, and roughly 5,000–10,000 routing matrix credits depending on candidate sets. Total first-run cost is well under the entry paid tier's monthly allocation. Subsequent quarterly runs, with geocode caching, cost a fraction of the first run. The free tier (3,000 calls/day) is sufficient to pilot the pipeline on a subset of the roster before committing to a paid plan.

Should we store member lat/lng in our membership system permanently?

That is an organisational data governance decision, not an API question. The minimum viable approach is to store geocoded coordinates only in the pipeline processing environment for the duration of the job, then discard them. If your membership system supports custom fields and your data governance policy permits it, storing verified lat/lng alongside each member record makes subsequent pipeline runs cheaper and faster. Either way, use no_record=1 on geocoding calls for member home addresses.

What if a member's address does not geocode at all?

A failed geocode (no results, or confidence below your threshold) means the member cannot be assigned by either method. Keep a "could not geocode" list and route those members through manual assignment. Common causes: PO Box addresses (which cannot be placed on a map), rural route addresses without house numbers, and recently built properties that have not yet propagated into the address database. For those members, a phone or email contact to confirm the address is the right fix — it also gives the membership team an excuse for a quality-data outreach campaign.

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 →