Planning field sales visits with route optimization

Turn a rep's weekly account list into an efficient visit plan: batch geocode, cluster by drive-time, sequence each day with the optimize endpoint.

| August 16, 2026
Planning field sales visits with route optimization

A field rep with forty accounts and five working days does not have a routing problem. They have a geometry problem disguised as a calendar problem. The routing is the easy part — once you know which accounts belong to Tuesday, the sequencing is mechanical. The hard part is the clustering: which accounts are close enough to each other in drive-time terms that visiting them in the same day actually makes sense?

Most field sales teams solve this with tribal knowledge. A veteran rep has a mental map built over years. A new hire follows the veteran's territory breakdown without understanding why, and then improvises daily. Both reps lose an hour a day to inefficient sequencing that they could not even name as a problem.

This post shows a different approach. You geocode the account list once, run a drive-time matrix to cluster accounts by day, sequence each day's stops with an optimize call, and print a day sheet with a static map. The inputs are a CSV and an API key. The outputs are five ordered stop lists and five printable maps — one per working day. The whole pipeline runs in under two minutes on a list of a hundred accounts. You do not need a routing SaaS, a GIS licence, or a proprietary tool.

This is explicitly about proactive weekly planning — before the rep leaves the office on Monday morning. It is not about reactive dispatch (assigning the nearest technician to an incoming ticket) or post-visit verification (reverse-geocoding a GPS ping to confirm a visit happened). Both of those are separate problems with separate architectures.

Why the current approach falls apart at scale

Tribal knowledge and personal mental maps work until the territory changes, a rep leaves, or the account base grows faster than any individual can track.

Territory hand-offs are brutal. When a rep leaves, their replacement inherits the account list but not the routing logic. That logic lives in the rep's head, not in any system. The new rep spends their first month rediscovering what the outgoing rep had optimised over three years. That rediscovery happens on the company's dime.

Growth breaks the mental map. A rep who covered thirty accounts well might cover fifty accounts poorly — not because of effort, but because the geometry of fifty accounts does not fit the same intuitive chunking as thirty. The rep adds the new accounts to the end of their existing route patterns rather than re-clustering from scratch, because re-clustering from scratch is genuinely hard to do by hand.

Account priority and drive-time interact in a way that spreadsheets cannot capture. A rep might know that Account A is worth three times Account B, but if Account A is forty-five minutes out of the way of everything else on Tuesday, it might be worth visiting on Wednesday alongside two other outliers rather than anchoring Tuesday's route around it. Balancing priority against drive-time is exactly what a matrix-and-optimize pipeline does, and it is exactly what a human eyeballing a spreadsheet cannot do efficiently.

The solution is not a black-box routing SaaS with a $400/month seat fee. It is three API calls in sequence, each one doing a specific and auditable job.

The three-call pipeline

Before the code, the architecture. Each call earns its place.

Call 1 — Batch geocode. Convert each account address to a lat/lng coordinate. This happens once, and the result is stored on the account record. It does not need to happen again unless the account moves. At 504M+ addresses across 63 countries, coverage is strong enough that the failure rate on a typical US commercial account list is under 1%. Those failures surface immediately as null coordinates and route to a manual-fix queue, not silently into a wrong cluster.

Call 2 — Routing N×M matrix. Compute the drive-time between every account and every other account. The result is a square matrix of travel times. Feed this matrix into a day-clustering algorithm (k-medoids or similar) to group accounts into five clusters where intra-cluster travel is cheap and inter-cluster travel is expensive. Each cluster becomes a day. The rep's home postcode or the office address anchors each cluster as the start/end depot.

Call 3 — Optimize endpoint, once per day. For each day's cluster, sequence the stops to minimise total drive time. The optimize endpoint takes an ordered list of waypoints and returns an ordered list — the Travelling Salesman solution for that day's geography. Each day's result is the visit sequence the rep follows.

A fourth call — static map per day — is optional but worth including. A printable image with numbered pins gives the rep something they can glance at between stops without pulling up a phone app.

Step 1: Batch geocode the account list

Start with a CSV. Minimum required columns: account_id, address. Optional but useful: priority (integer 1–5), min_visit_duration_minutes.

import csv
import os
import requests

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

def geocode_accounts(input_path, output_path):
    with open(input_path) as fin, open(output_path, "w", newline="") as fout:
        reader = csv.DictReader(fin)
        fields = reader.fieldnames + ["lat", "lng", "confidence"]
        writer = csv.DictWriter(fout, fieldnames=fields)
        writer.writeheader()
        for row in reader:
            r = requests.get(
                f"{API}/geocode",
                params={"q": row["address"], "api_key": KEY},
                timeout=15,
            )
            r.raise_for_status()
            result = r.json().get("results", [{}])[0]
            row["lat"] = result.get("lat")
            row["lng"] = result.get("lng")
            row["confidence"] = result.get("confidence")
            writer.writerow(row)

In production you would batch this through the web batch tool rather than calling one address at a time. Credits are consumed per address row — a 100-account list costs 100 credits. The geocoding result belongs on the account record in your CRM; it does not need to be re-fetched every week. An account's address changes rarely. If your CRM export already includes lat/lng, skip this step entirely and go straight to the matrix.

The confidence field matters here. Accounts that geocode with a confidence below 0.7 should be flagged for manual review before they enter the routing pipeline. A low-confidence geocode placed in the wrong cluster will send the rep to the wrong part of town.

The equivalent Node fetch version for teams working in a serverless environment:

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

async function geocodeAddress(address) {
  const url = `${API}/geocode?q=${encodeURIComponent(address)}&api_key=${KEY}`;
  const r = await fetch(url);
  if (!r.ok) throw new Error(`geocode http ${r.status}`);
  const body = await r.json();
  const result = body.results?.[0] ?? {};
  return { lat: result.lat ?? null, lng: result.lng ?? null, confidence: result.confidence ?? null };
}

Step 2: Build the drive-time matrix

With coordinates in hand, call the routing matrix endpoint. The matrix returns an N×N table of estimated travel times between every pair of accounts plus the depot (home office or rep's home postcode). The depot appears as index 0.

import json

def build_matrix(accounts, depot_lat, depot_lng):
    """
    accounts: list of dicts with lat, lng, account_id
    Returns: matrix dict from the API response
    """
    waypoints = [{"lat": depot_lat, "lng": depot_lng}] + [
        {"lat": a["lat"], "lng": a["lng"]} for a in accounts
    ]
    payload = {"waypoints": waypoints, "api_key": KEY}
    r = requests.post(
        f"{API}/routing/matrix",
        json=payload,
        timeout=60,
    )
    r.raise_for_status()
    return r.json()["durations"]  # NxN list of lists, seconds

The durations array is indexed [origin][destination]. durations[0][3] is the travel time in seconds from the depot to account index 2 (remembering index 0 is the depot). Keep the mapping between matrix index and account_id explicit in your code — index arithmetic bugs are silent and painful.

Curl equivalent for a small test:

curl -s -X POST "https://csv2geo.com/api/v1/routing/matrix" \
  -H "Content-Type: application/json" \
  -d '{
    "waypoints": [
      {"lat": 51.5074, "lng": -0.1278},
      {"lat": 51.5155, "lng": -0.0922},
      {"lat": 51.4995, "lng": -0.1248}
    ],
    "api_key": "'"$CSV2GEO_API_KEY"'"
  }'

Step 3: Cluster accounts into days

The matrix gives you pairwise travel costs. Now you need to partition the accounts into five groups (one per working day) such that each group is geographically coherent — accounts within the group are close to each other, accounts across groups are not.

K-medoids is the right algorithm here rather than k-means. K-means requires Euclidean distance and operates on coordinates; k-medoids operates on the actual travel-time matrix and picks real accounts as cluster centres, which means the "anchor" of each day's cluster is always a real account you can visit, not a geometric centroid that might sit in a river.

from typing import List
import random

def kmedoids(matrix, k, iterations=100, seed=42):
    """
    Simple k-medoids on a travel-time matrix.
    Returns: list of k lists of indices (each list is one day's accounts).
    matrix[i][j] = travel time seconds from i to j.
    Index 0 is the depot; accounts are indices 1..N.
    """
    random.seed(seed)
    n = len(matrix)
    account_indices = list(range(1, n))  # exclude depot
    medoids = random.sample(account_indices, k)

    for _ in range(iterations):
        # Assign each account to nearest medoid
        clusters = {m: [] for m in medoids}
        for idx in account_indices:
            nearest = min(medoids, key=lambda m: matrix[idx][m])
            clusters[nearest].append(idx)

        # Recompute medoids
        new_medoids = []
        for m, members in clusters.items():
            if not members:
                new_medoids.append(m)
                continue
            best = min(members, key=lambda c: sum(matrix[c][j] for j in members))
            new_medoids.append(best)

        if set(new_medoids) == set(medoids):
            break
        medoids = new_medoids

    return list(clusters.values())

This implementation is intentionally simple — no external dependencies, auditable, easy to replace with a more sophisticated solver if your account lists grow large. For lists under 200 accounts it converges quickly. For lists above 500, add a smarter initialisation (the k-medoids++ variant) or use the scikit-learn-extra KMedoids class if your pipeline already carries that dependency.

Priority weighting: if certain accounts must be visited every week regardless of geography (your top-tier accounts), assign them to days first as fixed anchors before running k-medoids on the remainder. This is the "constrained clustering" variant — you are not looking for the globally optimal partition, you are looking for the best partition given a set of must-visit constraints. Model those constraints as penalty terms added to the distance matrix, or simply pre-assign the anchor accounts and run the algorithm on the residual.

Step 4: Sequence each day's stops with the optimize endpoint

Each cluster is now a list of account indices. Map them back to coordinates and call the optimize endpoint. The endpoint takes an ordered list of waypoints and returns the optimal sequence — the Travelling Salesman solution for that day.

def optimize_day(day_accounts, depot_lat, depot_lng):
    """
    day_accounts: list of dicts with lat, lng, account_id
    Returns: ordered list of account dicts in visit sequence
    """
    waypoints = (
        [{"lat": depot_lat, "lng": depot_lng, "label": "Depot (start)"}]
        + [{"lat": a["lat"], "lng": a["lng"], "label": a["account_id"]}
           for a in day_accounts]
        + [{"lat": depot_lat, "lng": depot_lng, "label": "Depot (end)"}]
    )
    r = requests.post(
        f"{API}/routing/optimize",
        json={"waypoints": waypoints, "api_key": KEY},
        timeout=30,
    )
    r.raise_for_status()
    ordered_indices = r.json()["order"]  # list of integers, 0-indexed into waypoints
    # Strip depot start (index 0) and depot end (last index)
    account_order = [day_accounts[i - 1] for i in ordered_indices if 0 < i < len(waypoints) - 1]
    return account_order

The order field in the response is a list of indices into the waypoints array you sent. Index 0 is always the depot start; the last index is always the depot end. Everything in between is your accounts in drive-time-optimal order.

Run this call once per day, five times per week plan:

depot = {"lat": 51.5074, "lng": -0.1278}
weekly_plan = []
for day_idx, cluster in enumerate(clusters):
    day_accounts = [accounts[i - 1] for i in cluster]  # map back from matrix indices
    ordered = optimize_day(day_accounts, depot["lat"], depot["lng"])
    weekly_plan.append({"day": day_idx + 1, "stops": ordered})

Step 5: Generate a printable day sheet with a static map

The optimized stop sequence is useful data. A printable day sheet with a labelled map is what the rep actually uses in the field. The static maps endpoint takes a list of pins with labels and returns a PNG.

def build_day_sheet_map(ordered_stops, depot_lat, depot_lng, day_num):
    pins = [{"lat": depot_lat, "lng": depot_lng, "label": "S", "color": "green"}]
    for i, stop in enumerate(ordered_stops, start=1):
        pins.append({
            "lat": stop["lat"],
            "lng": stop["lng"],
            "label": str(i),
            "color": "blue",
        })
    r = requests.post(
        f"{API}/staticmap",
        json={"pins": pins, "width": 800, "height": 600, "api_key": KEY},
        timeout=20,
    )
    r.raise_for_status()
    path = f"day_{day_num}_map.png"
    with open(path, "wb") as f:
        f.write(r.content)
    return path

The output is an 800×600 PNG with numbered blue pins and a green start marker. Print it, email it, or embed it in the calendar invite. A rep who glances at this image before leaving the office knows the geographic shape of the day without opening a navigation app.

The stop list alongside the map should include the account name, address, phone number, priority tier, and a "minimum visit duration" field if your CRM carries it. That last field lets the rep know whether stop 3 is a five-minute drop-in or a forty-five-minute demo — information that changes how tightly they should pack the calendar.

What this pipeline does not do

Honest scope, because over-promising on routing tools is a tradition with a poor track record.

It does not account for appointment windows. If Account B will only meet between 10:00 and 11:30 on Tuesdays, the optimizer needs to know about that constraint. The basic optimize endpoint sequences for drive-time; time-window constraints require a more complex formulation. Handle fixed-appointment accounts by pre-pinning them to specific days before running the clustering step.

It does not know about road closures or real-time traffic. The matrix and optimize endpoints use typical drive times, not live traffic. A rep working in a city with predictable rush-hour congestion should shift their depot departure time rather than expecting the route to route around congestion dynamically.

It does not integrate with your CRM. There is no CRM connector endpoint. The export/import is your responsibility. The pipeline's output is a CSV and a set of PNGs — import them into Salesforce, HubSpot, or a Google Sheet the same way you would import any CSV. This is deliberate: CRM integrations are version-sensitive, authentication-sensitive, and tend to become the most fragile part of any pipeline. Keep the API pipeline stateless and let the CRM integration be a thin import layer you own.

Visits per day is your measurement. The pipeline will not tell you how many more accounts your reps will visit per day after implementing it. That is your baseline measurement to take, and your post-implementation measurement to compare. Any vendor that quotes you a specific uplift percentage without knowing your territory density, your account mix, your rep tenure, and your current baseline is making up a number.

Cost model for a typical sales territory

A single rep, forty accounts, planned weekly.

  • Initial geocoding: 40 credits (one-time, cached on the account record). Cost: negligible — well within the free tier's 3,000 calls/day.
  • Weekly matrix call: 1 credit for the N×M matrix of 41 waypoints (40 accounts + depot). Cost: 1 credit per planning run.
  • Weekly optimize calls: 5 credits (one per day). Cost: 5 credits per planning run.
  • Static map calls: 5 credits (one per day). Cost: 5 credits per planning run.
  • Total weekly cost per rep: 11 credits.

For a team of twenty reps, that is 220 credits per week, or roughly 11,500 credits per year. The free tier covers this entirely at 3,000 calls/day — a planning run for twenty reps takes fewer than 300 API calls in total and completes in under two minutes. If your account lists are larger (200+ accounts per rep) or your team is larger, you are well inside the entry paid tier at $54/month for 100,000 calls. See live pricing at csv2geo.com/pricing/api.

Caching and re-planning cadence

Geocodes are the most expensive thing to compute and the least likely to change. Cache them on the account record and only re-geocode when the address field is updated. The matrix and optimize calls are cheap enough that re-running the full weekly plan every Sunday night as a scheduled job is reasonable — it picks up any new accounts added during the week and re-clusters around them.

One pattern worth encoding: if fewer than 10% of accounts changed since last week's plan, do not re-run the full matrix. Re-run only the days that were affected by the change. This is trivial to implement — diff the account list against a hash stored after last week's run, identify which clusters the new or changed accounts fall into based on their nearest medoid, and re-optimize only those days. See Caching Geocoding Results — 90% Cost Reduction for the broader caching philosophy that applies here.

Observability: what to instrument

A routing pipeline that silently produces bad routes is worse than one that fails loudly. Three things to monitor.

Geocoding confidence histogram. Plot the distribution of confidence scores across your account list. A spike of low-confidence results is a data-quality problem upstream — bad addresses in the CRM, address fields concatenated in the wrong order, ZIP codes missing. Fix the data, not the geocoder.

Cluster balance metric. After clustering, compare the number of accounts per day. A balanced plan has roughly equal counts. A plan where one day has two accounts and another has twelve indicates that the depot location, the account distribution, or the number of working days is poorly matched to the actual geography. Surface this as a warning in the planning tool — a rep should be able to tell the system "I want no more than ten accounts on any day" and have the constraint respected.

Planned distance versus actual GPS track. If your field app records GPS, compare the planned route distance to the actual distance driven. A large systematic gap (actual is consistently 40% longer than planned) suggests the matrix travel times are wrong for that geography — possibly because the typical-speed assumptions do not match traffic patterns in that territory. See Observability for Geocoding Pipelines for the instrumentation pattern.

Frequently Asked Questions

Do I need to re-geocode my account list every week?

No. Geocode once and store the result on the account record. Re-geocode only when an address changes. A hundred-account list geocoded on Monday lasts until an account moves or a new account is added.

What if a rep has a mix of must-visit accounts and optional accounts?

Assign must-visit accounts to days first as fixed anchors, then run k-medoids clustering on the remaining optional accounts. The clusters form around the anchors, and the optimize call sequences all stops — fixed and optional — for each day in drive-time order.

How does account priority interact with the routing?

Priority belongs in the clustering step, not the sequencing step. A high-priority account is one you commit to visiting every week regardless of where it sits geographically. Model this as a fixed assignment before clustering. Once the day assignment is settled, sequencing is purely a drive-time problem — the optimize endpoint does not know or care about priority.

Can the pipeline handle reps who start from home rather than a central office?

Yes. The depot coordinate is just a lat/lng. Pass each rep's home postcode (geocoded to lat/lng) as their depot. If a rep starts from home on Monday but returns to the office on Friday, use a different depot for the final day's optimize call.

Is there an SDK for this, or do I have to use REST?

Python and Node SDKs exist, but the REST surface is simple enough that most enterprise pipelines wrap it in their own thin HTTP client rather than taking an SDK dependency. The examples in this post use requests and native fetch — no library pinning, no upgrade treadmill. If you prefer the SDK convenience for prototyping, use it; switch to direct REST calls before you deploy to production.

What is the maximum number of accounts the pipeline can handle?

The matrix endpoint supports up to several hundred waypoints per call. For very large territories (300+ accounts per rep), split the territory into sub-regions first using a lightweight spatial partition (e.g. divide by lat/lng quadrant), run the matrix and optimize within each sub-region, and merge the day assignments. At that scale you are also likely dealing with a multi-week planning horizon rather than a single-week plan.

How does this differ from reactive dispatch for service technicians?

Reactive dispatch assigns the nearest available technician to an incoming ticket in real time. This pipeline plans a week of proactive sales visits in advance. The API endpoints overlap (both use the routing matrix and optimize), but the problem structure is different: reactive dispatch is a streaming assignment problem; field sales planning is a weekly batch optimisation problem. See Dispatch Console — 5,000 Stops Per Day for the reactive 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 →