Territory carving for a 200-person SDR org in a weekend

Geocode 50k accounts, assign them to 200 SDRs, and publish clean territory maps — all over a weekend using batch geocoding and the Divisions endpoint.

| June 01, 2026
Territory carving for a 200-person SDR org in a weekend

Every RevOps team eventually faces the same crunch: a reorg lands on Thursday, 200 SDRs need clean territory assignments by Monday, and the CRM is a spreadsheet held together with VLOOKUP and good intentions. The data that drives territory carving — account addresses, employee counts, deal-stage distributions — has never been geocoded. Nobody knows which accounts actually sit in which postal geography, let alone which custom territory polygon you drew last quarter.

This post walks through the exact workflow that gets you from raw CRM export to published territory assignments over a weekend. No GIS engineer on retainer. No enterprise mapping contract. Two API endpoints, a couple of hundred lines of Python or Node, and a clear-eyed view of where the process breaks and how to handle it.

The endpoints are batch geocoding and the Divisions lookup. Together they answer the two questions territory carving actually needs answered: *where is each account?* and *which territory does that location belong to?*

---

Why geocoding is the actual bottleneck in territory design

Territory carving discussions burn most of their time on equity debates — revenue potential per rep, named accounts, strategic verticals. That is the right fight to have. What nobody expects is how much time evaporates before you can even start that conversation, simply because the account addresses are garbage.

A typical enterprise CRM export looks like this: fifteen percent of addresses are missing the state. Eight percent have the city in the street field. Three percent are headquarters addresses for holding companies based in Delaware that have nothing to do with where the actual buyers sit. Another five percent are duplicates — the same account entered twice by two different SDRs who found it on different prospecting tools.

The geocoding step surfaces all of that. When you run fifty thousand CRM rows through batch geocoding and look at the confidence scores, the low-confidence addresses light up like a fault map. Low confidence is not just a data quality indicator — it is a territory-integrity indicator. An account you cannot reliably locate is an account that will generate a dispute between two SDRs six weeks after the reorg, when the deal closes and nobody agreed on whose territory it was.

Fix the data problem first. The geocoding run is how you find it.

The two endpoints

`POST /api/v1/geocode/batch` — takes a JSON array of address strings and returns a geocoded result for each, with a confidence score between 0 and 1. Up to 1,000 addresses per request. The response preserves the order of the input array, so you can zip() it straight back to your input rows without a join.

`GET /api/v1/divisions` — takes a lat/lng coordinate and returns the administrative divisions that contain it: country, state or region, county, and postal code. You can also pass a custom polygon set if your territories are drawn on commercial boundaries, zip-cluster shapes, or anything other than the standard administrative hierarchy.

Those two endpoints are the whole pipeline. Geocoding tells you where; Divisions tells you what territory that "where" maps to. The rest is bookkeeping.

What the response looks like before you write any orchestration

Ground everything in the raw HTTP shapes. A single-address geocode:

curl -s -G "https://csv2geo.com/api/v1/geocode" \
  --data-urlencode "q=350 Fifth Avenue, New York, NY 10118" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

Returns:

{
  "results": [
    {
      "formatted": "350 5th Ave, New York, NY 10118, USA",
      "lat": 40.7484,
      "lng": -73.9967,
      "confidence": 0.97,
      "components": {
        "house_number": "350",
        "road": "5th Ave",
        "city": "New York",
        "state": "New York",
        "state_code": "NY",
        "postcode": "10118",
        "country_code": "us"
      }
    }
  ]
}

And the Divisions call against those coordinates:

curl -s -G "https://csv2geo.com/api/v1/divisions" \
  --data-urlencode "lat=40.7484" \
  --data-urlencode "lng=-73.9967" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

Returns:

{
  "lat": 40.7484,
  "lng": -73.9967,
  "divisions": {
    "country": "United States",
    "country_code": "us",
    "state": "New York",
    "state_code": "NY",
    "county": "New York County",
    "postcode": "10118"
  }
}

Every field in both responses has a parallel in your CRM schema. state_code is the territory key if you carve by state. postcode is the key if you carve by ZIP cluster. county covers the handful of RevOps teams who align territories to county-level market definitions. You do not need to pick one; log all four and let the territory-assignment logic pick the key it needs.

The full pipeline in Python

A production-grade script for a 50,000-account CRM export, top to bottom. No SDK. One dependency beyond the standard library: requests.

import csv
import os
import time
import requests

API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_API_KEY"]
GEOCODE_BATCH = 1000   # /geocode/batch accepts up to 1,000 per request
DIVISION_BATCH = 500   # /divisions batch accepts up to 500 per request
CONFIDENCE_THRESHOLD = 0.75
RETRY_SLEEP = 2        # seconds; double on each 429, see exponential backoff post


def chunks(seq, n):
    for i in range(0, len(seq), n):
        yield seq[i : i + n]


def geocode_batch(addresses):
    """Return geocoded results in the same order as input addresses."""
    r = requests.post(
        f"{API}/geocode/batch",
        json={"addresses": addresses, "api_key": KEY},
        timeout=60,
    )
    if r.status_code == 429:
        time.sleep(RETRY_SLEEP)
        return geocode_batch(addresses)  # naive single retry; wrap in backoff for prod
    r.raise_for_status()
    return r.json()["results"]


def fetch_divisions(coords):
    """coords: list of (lat, lng). Returns list of division dicts."""
    points = "|".join(f"{lat},{lng}" for lat, lng in coords)
    r = requests.get(
        f"{API}/divisions",
        params={"points": points, "api_key": KEY},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["results"]


def run(input_path, output_path):
    with open(input_path) as fin, open(output_path, "w", newline="") as fout:
        reader = csv.DictReader(fin)
        out_fields = reader.fieldnames + [
            "lat", "lng", "confidence",
            "geocode_formatted",
            "division_state", "division_state_code",
            "division_county", "division_postcode",
            "territory_flag",
        ]
        writer = csv.DictWriter(fout, fieldnames=out_fields)
        writer.writeheader()

        rows = list(reader)
        print(f"Loaded {len(rows):,} accounts")

        # Pass 1: geocode in batches of 1,000
        geocoded = []
        for batch in chunks(rows, GEOCODE_BATCH):
            addrs = [r["address"] for r in batch]
            results = geocode_batch(addrs)
            for row, geo in zip(batch, results):
                row["lat"]               = geo.get("lat")
                row["lng"]               = geo.get("lng")
                row["confidence"]        = geo.get("confidence")
                row["geocode_formatted"] = geo.get("formatted", "")
            geocoded.extend(batch)

        print("Geocoding complete")

        # Pass 2: divisions for rows that cleared the confidence threshold
        good = [r for r in geocoded if r["lat"] and
                float(r["confidence"] or 0) >= CONFIDENCE_THRESHOLD]
        bad  = [r for r in geocoded if r not in good]

        coords = [(r["lat"], r["lng"]) for r in good]
        all_divs = []
        for batch in chunks(coords, DIVISION_BATCH):
            all_divs.extend(fetch_divisions(batch))

        for row, div in zip(good, all_divs):
            d = div.get("divisions", {})
            row["division_state"]      = d.get("state", "")
            row["division_state_code"] = d.get("state_code", "")
            row["division_county"]     = d.get("county", "")
            row["division_postcode"]   = d.get("postcode", "")
            row["territory_flag"]      = "ok"

        for row in bad:
            row["territory_flag"] = "needs_review"

        writer.writerows(good)
        writer.writerows(bad)

    print(f"Done. {len(good):,} geocoded cleanly, {len(bad):,} flagged for review.")


if __name__ == "__main__":
    run("accounts_export.csv", "accounts_enriched.csv")

A 50,000-row CRM export becomes 50 batch geocoding calls and 100 Divisions calls at most. On a residential connection with no concurrency tricks, the wall-clock time is under five minutes. See Concurrency Tuning for Geocoding Pipelines if you want to push it below 90 seconds with a thread pool — the endpoint is stateless and safe to parallelise.

The same thing in Node

For teams running a JavaScript stack. Same REST calls, no geocoding SDK, no version pinning.

import fs from 'node:fs';
import { parse } from 'csv-parse/sync';
import { stringify } from 'csv-stringify/sync';

const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_API_KEY;
const GEOCODE_BATCH = 1000;
const DIVISION_BATCH = 500;
const CONFIDENCE_THRESHOLD = 0.75;

function chunk(arr, n) {
  const out = [];
  for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n));
  return out;
}

async function geocodeBatch(addresses) {
  const r = await fetch(`${API}/geocode/batch`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ addresses, api_key: KEY }),
  });
  if (!r.ok) throw new Error(`Geocode batch failed: ${r.status}`);
  const json = await r.json();
  return json.results;
}

async function fetchDivisions(coords) {
  const points = coords.map(([lat, lng]) => `${lat},${lng}`).join('|');
  const url = `${API}/divisions?points=${encodeURIComponent(points)}&api_key=${KEY}`;
  const r = await fetch(url);
  if (!r.ok) throw new Error(`Divisions failed: ${r.status}`);
  const json = await r.json();
  return json.results;
}

async function run(inputPath, outputPath) {
  const raw = fs.readFileSync(inputPath, 'utf8');
  const rows = parse(raw, { columns: true });

  // Geocode
  for (const batch of chunk(rows, GEOCODE_BATCH)) {
    const addrs = batch.map(r => r.address);
    const results = await geocodeBatch(addrs);
    results.forEach((geo, i) => {
      batch[i].lat = geo.lat ?? '';
      batch[i].lng = geo.lng ?? '';
      batch[i].confidence = geo.confidence ?? '';
      batch[i].geocode_formatted = geo.formatted ?? '';
    });
  }

  const good = rows.filter(r => r.lat && parseFloat(r.confidence) >= CONFIDENCE_THRESHOLD);
  const bad  = rows.filter(r => !r.lat || parseFloat(r.confidence) < CONFIDENCE_THRESHOLD);

  // Divisions
  const coords = good.map(r => [r.lat, r.lng]);
  const allDivs = [];
  for (const batch of chunk(coords, DIVISION_BATCH)) {
    allDivs.push(...(await fetchDivisions(batch)));
  }
  good.forEach((row, i) => {
    const d = allDivs[i]?.divisions ?? {};
    row.division_state      = d.state ?? '';
    row.division_state_code = d.state_code ?? '';
    row.division_county     = d.county ?? '';
    row.division_postcode   = d.postcode ?? '';
    row.territory_flag = 'ok';
  });
  bad.forEach(row => { row.territory_flag = 'needs_review'; });

  const output = stringify([...good, ...bad], { header: true });
  fs.writeFileSync(outputPath, output);
  console.log(`Done. ${good.length} ok, ${bad.length} flagged.`);
}

run('accounts_export.csv', 'accounts_enriched.csv');

The SDKs for Python and Node exist and are fine for quick experiments. For a pipeline that will run in production every quarter when the reorg inevitably happens again, the raw REST approach is more maintainable — there is no SDK version to pin, no transitive dependency to audit, and the HTTP contract is stable.

---

HowTo: Territory carving over a weekend

Step 1: Export and normalise the CRM data

Pull the full account export from your CRM. You want at minimum: account_id, a composite address field or separate street, city, state, postcode, country columns, and whatever commercial signals drive territory equity — annual revenue band, employee count, open opportunity value.

Before geocoding, spend 30 minutes on normalisation. Concatenate separate address fields into a single string: f"{street}, {city}, {state} {postcode}, {country}". Strip whitespace, drop records where the entire address field is empty, and flag records where city and postcode are both missing — those will score poorly regardless of how good the geocoder is, and they are worth a separate manual pass. An account with street = "Various" and no further data is not a geocoding problem; it is a data entry problem that should go back to the originating sales rep.

A realistic 50,000-account export has roughly 2,000–5,000 records that benefit from normalisation before the geocoding run. The 30 minutes pays for itself in cleaner confidence scores on the output.

Step 2: Run the geocoding pass and audit confidence scores

Run the batch geocoding script above against the normalised CSV. When it finishes, open the enriched output and filter to confidence < 0.75. For a 50,000-row dataset, you should see somewhere between 2,000 and 6,000 flagged rows — call it 5% to 12%, depending on how aggressively your sales team has been entering addresses.

Do not auto-assign these accounts to territories. An account with confidence = 0.45 geocoded to a centroid somewhere in the middle of Nevada almost certainly has a bad address. Auto-assigning it to a Nevada rep creates a territory dispute the moment the deal closes and the rep discovers the account is actually based in Colorado.

The right pattern: export the flagged rows to a separate CSV, attach it to a Slack message to the revenue operations team, and give them until Sunday evening to fix what they can. Anything still unfixed by Monday goes into a "disputed / unassigned" bucket that a manager owns. Do not pretend precision you do not have.

Step 3: Assign territories using the Divisions output

With the clean accounts geocoded and Divisions-enriched, the territory assignment is a SQL join or a Python dict lookup — not a spatial operation. If your territories carve by state, the assignment key is division_state_code. If you carve by ZIP cluster, the key is division_postcode. If you use custom polygons defined by your sales strategy team, you need one extra step — but the Divisions endpoint supports custom polygon sets, so the pattern is the same; you just pass your polygon IDs instead of relying on the standard administrative hierarchy.

A straightforward state-based assignment:

# territory_map.py
# Built from your RevOps territory design document
TERRITORY_MAP = {
    "NY": "rep_alice@company.com",
    "NJ": "rep_alice@company.com",
    "CT": "rep_alice@company.com",
    "CA": "rep_bob@company.com",
    # ... 200 reps, ~50 state groups
}

import csv

with open("accounts_enriched.csv") as fin, \
     open("accounts_assigned.csv", "w", newline="") as fout:
    reader = csv.DictReader(fin)
    fields = reader.fieldnames + ["assigned_rep"]
    writer = csv.DictWriter(fout, fieldnames=fields)
    writer.writeheader()
    for row in reader:
        state_code = row.get("division_state_code", "")
        row["assigned_rep"] = TERRITORY_MAP.get(state_code, "UNASSIGNED")
        writer.writerow(row)

For a 200-rep org with state-based territories, this takes about four minutes to write and runs in under three seconds. The complexity lives in the TERRITORY_MAP dictionary, which belongs to your sales strategy document, not in the geocoding pipeline.

Step 4: Reconcile against the previous territory assignments

Pull the previous territory assignments from CRM — the ones the reorg is replacing. Join them to the new assignments on account_id. Flag every row where old_rep != assigned_rep. This is your change list, and it is the document that sales leadership actually needs to sign off on.

The change list surfaces three categories:

Clean moves — accounts shifting from one rep to another with high geocode confidence and unambiguous Divisions output. These go live Monday.

Disputed moves — accounts where the division_state_code puts them in Rep A's territory but the account's primary contact is in Rep B's patch, or where the geocode confidence is in the 0.75–0.85 range and you are not fully confident in the location. Flag these for a manager to adjudicate before the new comp plan takes effect.

Multi-location accounts — headquarters in New York, manufacturing in Ohio, procurement in Texas. The geocoding pipeline will return whichever address the CRM stores, and that may not reflect where the buying decision actually lives. These accounts need a named-account exception list, not a geocoding rule. Build a column called territory_override that the sales leader can populate; the assignment logic checks that column first.

The reconciliation step is what makes Monday morning survivable. Sales leaders who can see exactly which 4,200 accounts changed hands, and why, are able to field SDR questions confidently. Sales leaders handed a black-box reassignment file are in for a painful week.

Step 5: Publish and lock

The final output is a CSV with account_id, assigned_rep, territory_flag, and the geocoded lat/lng. Import it back into CRM. Tag every record with the geocoding run date — you will want to know, six months from now, whether an account's territory assignment was based on a geocode from before or after an address correction.

Lock the territory file by committing it to a private Git repository with a timestamp in the filename: territories_2026-06-01.csv. Every time you re-run the pipeline — next reorg, next quarter's expansion into new states — you have a diff-able history. git diff territories_2026-06-01.csv territories_2026-09-01.csv shows exactly which accounts moved and who they moved to. That is the audit trail that HR and legal occasionally ask for when a commission dispute gets escalated.

---

Failure modes that will find you on Saturday night

Territory carving pipelines have a particular failure mode profile because you are running them under time pressure, with data you have never fully audited, against a hard Monday deadline.

The 429 wall. If you run the geocoding pass with no concurrency control against a 50,000-row file and the API returns 429s, your naive retry loop will hammer the endpoint and backoff poorly. The fix is exponential backoff with jitter, applied at the batch level, not the per-address level. A 1,000-address batch that gets a 429 should wait, not be split into 1,000 individual retries. The free tier offers 3,000 calls per day; on a paid plan with 100,000 monthly calls, a 50,000-row run in batches of 1,000 costs 50 credits — well within budget — but fire it at 20 concurrent threads and you will trigger rate limiting regardless of your plan bracket.

The silent nulls. Both the geocoding and Divisions endpoints return null for fields they cannot resolve. A confidence score of null is distinct from a confidence score of 0.0. An address that returns all nulls — lat is null, lng is null, confidence is null — almost always means the address string is so malformed the geocoder gave up entirely. These are not low-confidence rows; they are not-geocoded rows. Your confidence threshold filter (>= 0.75) will correctly exclude them, but make sure your downstream code handles None / null rather than treating it as 0. A float comparison against None throws in Python and silently returns false in JavaScript — both will produce incorrect territory flags if you have not tested with null inputs.

The country boundary problem. If your account book is international, you need to decide what your territory carving key is for non-US accounts. division_state_code for a German account returns a German state code (BY for Bavaria, NW for North Rhine-Westphalia), which may or may not map to anything in your TERRITORY_MAP. Build an explicit fallback: if division_country_code != "us", assign the account to the correct international territory by country code. Do not let a German account silently fall into an unassigned bucket because Bavaria does not appear in your US state-based map.

The duplicate account explosion. A 50,000-row CRM export for a 200-rep org often contains 8,000–12,000 duplicate accounts — the same company entered by multiple reps or imported multiple times from different data providers. Geocoding duplicates is not a problem in itself; assigning duplicates to different reps because they have slightly different addresses is. Run a deduplication step on account_id before geocoding. If the CRM does not have reliable account_id values (which is a more serious problem), deduplicate on normalised company name + postcode before the geocoding pass. The time you spend deduplicating is time you save in SDR disputes next month.

Cost arithmetic for RevOps conversations

Finance will ask. Here are the numbers you need.

  • 50,000 accounts × 1 geocoding credit = 50,000 credits
  • 50,000 accounts × 1 Divisions credit = 50,000 credits
  • Total: 100,000 credits for the full pipeline

At $54/month for 100,000 calls, the entire territory carving pipeline for a 200-person org costs $54 in API credits. One run. If you cache the results — which you should; geocoded addresses do not expire — and re-run quarterly to pick up new accounts only, the incremental cost per quarter is the number of net-new accounts added to CRM multiplied by two credits each.

For an org adding 2,000 net-new accounts per quarter, that is 4,000 credits — less than $3. The pricing page has the current bracket breakdown. The free tier (3,000 calls per day, no credit card required) is enough to run a pilot against a 1,000-account sample before committing.

The cost argument against doing this properly — "we can't afford a geocoding API" — is not a cost argument. It is a prioritisation argument dressed up in budget language.

---

Frequently Asked Questions

How many addresses can we geocode in a single API call?

The batch geocoding endpoint accepts up to 1,000 addresses per request. The Divisions endpoint accepts up to 500 coordinates per request (passed as a |-separated list). For a 50,000-account CRM export, that is 50 geocoding calls and 100 Divisions calls — both run comfortably within the paid plan's monthly allocation in a single pipeline run.

What does a confidence score actually mean for territory assignment purposes?

Confidence is a 0–1 score that reflects how well the geocoder was able to resolve the input address to a specific location. A score above 0.9 means the address matched to a building-level location. Scores between 0.75 and 0.9 typically indicate a reliable street-level match. Below 0.75, the geocoder is interpolating — the result is probably in the right general area but may be several streets off. For territory carving, treat anything below 0.75 as "needs human review before assignment." See Geocoding Confidence Scores Explained for the full breakdown.

Our territories are not based on state or county boundaries — they are custom regions. Does the Divisions endpoint support that?

Yes. You can pass custom polygon definitions to the Divisions endpoint and it will return which custom region contains each coordinate. This is the right approach for territory designs based on ZIP-cluster groupings, metro-area definitions, or bespoke commercial geographies. The standard state/county/postcode hierarchy is just the default; custom polygon sets are a first-class use case.

Is it safe to re-run the geocoding pipeline on the same accounts every quarter?

Yes, with one caveat: cache your results. Addresses do not change on quarterly timescales. If you geocode 50,000 accounts in June and store the results in your CRM or data warehouse, the July pipeline should only call the API for net-new accounts — not re-geocode the existing 50,000. Caching Geocoding Results — 90% Cost Reduction covers the caching pattern in detail.

How do we handle accounts with multiple addresses — headquarters, regional office, and a billing address?

Geocode all of them and store all three lat/lng pairs on the account record. The territory assignment logic then needs a rule: is the territory driven by headquarters location, by the location of the primary buying contact, or by the billing address? That is a sales strategy question, not a geocoding question. Once you have decided, apply the rule in the assignment step. Store the other geocoded addresses anyway — they are useful for dispute resolution.

What happens if the Divisions endpoint cannot resolve the administrative division for a coordinate?

The endpoint returns a divisions object with null values for the fields it cannot resolve. For a coordinate in the middle of the ocean, all division fields will be null. For a coordinate that falls on an administrative boundary, the endpoint picks the most likely enclosing division. In practice, for a CRM account list of business addresses, null division results are rare — under 0.5% in most production runs — and are most commonly caused by coordinates that failed geocoding but were passed to Divisions anyway. The territory_flag = "needs_review" path in the script above handles these correctly.

The SDKs look convenient. Why recommend raw REST?

SDKs are useful for exploration. For a pipeline that runs in production every quarter — possibly triggered by a script in your RevOps team's internal tooling, possibly by a junior analyst who has never touched the underlying package — raw REST is more durable. There is no SDK version to audit, no transitive dependency to update, and no breaking change to absorb when the SDK major version increments. The REST contract is stable; the SDK surface is not. Acknowledge the SDKs exist, use them in development, ship REST in production.

---

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 →