Auto-assigning service tickets to the nearest technician

Reverse geocode a customer location, find the nearest technician by category, and auto-assign a service ticket — one REST call each, no SDK required.

| May 29, 2026
Auto-assigning service tickets to the nearest technician

Most field-service dispatch tools still rely on a human reading a map. A ticket comes in, a dispatcher eyeballs a board, picks the technician who looks closest, and rings their radio. That works up to about forty concurrent technicians and a quiet afternoon. It does not work at scale, and it breaks entirely when the dispatcher goes to lunch.

This post replaces the human-with-a-map with a two-call pattern. When a service ticket arrives, you reverse geocode the customer's coordinates to get a clean address and jurisdiction context, then query Places by category to find the nearest available technician registered in your own service-capacity layer. The assignment is automatic. The dispatcher's job shifts from "pick someone" to "review escalations only."

The pattern applies to any field-service industry: HVAC, telecoms, pest control, utilities, medical equipment, broadband installation. The customer has a location; you have technicians spread across a territory; you want the closest match from the right skill category. Two REST endpoints, one database join, one queue write.

Why the naive approach breaks

The simplest version of this logic — "find the technician whose last known GPS coordinate is closest to the customer" — breaks in three ways in production.

Category mismatch. A technician certified for commercial refrigeration is not interchangeable with a residential plumber, even if they are parked thirty metres apart. Proximity without category filtering produces bad assignments that get cancelled and reassigned, doubling the travel cost and burning customer goodwill.

Stale location data. GPS heartbeats from field devices come in every thirty to ninety seconds. A technician who finished their last job two minutes ago and has already left the area will appear "closest" until their device sends the next ping. Assigning them a ticket they cannot realistically reach is worse than assigning someone slightly farther away who is genuinely stationary.

Address ambiguity. The customer types "47 High Street" into a form. Without reverse geocoding the incoming lat/lng to confirm the jurisdiction — because there are fourteen High Streets in the metro — you do not know whether you are dispatching into the Central district or the Eastern district, and your SLA zones are wrong. A clean address, a postcode, and a district code all come back from a single reverse geocode call.

Fixing all three requires: a reverse geocode on inbound coordinates, a category-filtered technician query, and a freshness gate before the assignment commits.

The two endpoints

`GET /api/v1/reverse` — takes a lat and lng, returns the nearest address, postcode, city, district, county, and country. Use this to resolve the customer's coordinates into structured address data that your job-management system can store, display, and use for SLA-zone routing.

`GET /api/v1/places/nearby` — takes lat, lng, radius (metres), and categories (comma-separated), returns a ranked list of matching places sorted by distance. In your service-dispatch context, the "places" are technician home bases, depots, or last-known positions that you have registered in your own location layer via the Places write API. Category tags you assign yourself — hvac_commercial, plumbing_residential, fibre_splicing, whatever taxonomy matches your workforce structure.

The two calls are independent — run them in parallel for the lowest latency, or sequentially if you need the district from the reverse geocode before you know which technician pool to query.

Both endpoints are part of the same 56-endpoint platform, same API key, same billing bucket.

The assignment logic, end to end

Here is the full pattern from ticket creation to assignment write, in prose first, then code.

  1. A new service ticket arrives via webhook or form POST. The payload contains the customer's lat/lng (from the mobile app, the caller's phone GPS, or a geocoded address field), the fault category, and the customer's contract tier.
  2. Fire two parallel requests: reverse geocode the lat/lng to get the clean address and district code; query Places nearby with the fault category and a radius appropriate to your territory density.
  3. The reverse geocode response gives you the address string to store in the ticket, the district code to look up the right SLA clock, and the postcode for any postal-zone pricing rules.
  4. The Places nearby response returns a list of technician records (from your own registered locations), sorted by distance. Apply your freshness gate — reject any record whose last-position timestamp is older than five minutes. Apply your availability check — query your job-management DB for WHERE tech_id = ? AND status = 'available'. Take the first technician who passes both filters.
  5. Write the assignment: update the ticket with assigned_tech_id, assigned_at, estimated_drive_minutes (derived from the distance field in the Places response divided by a territory-tuned average speed). Push a push notification to the technician's device. Done.

No dispatcher needed for the happy path. The dispatcher reviews only: no available technician within radius (escalate), SLA tier that requires manual sign-off (flag), or a ticket where the reverse geocode confidence is low (verify address before dispatching).

The code

curl — test the reverse geocode

curl -s "https://csv2geo.com/api/v1/reverse" \
  --data-urlencode "lat=51.5074" \
  --data-urlencode "lng=-0.1278" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY" \
  -G

Response:

{
  "result": {
    "address": "10 Downing Street, Westminster, London SW1A 2AA, GB",
    "postcode": "SW1A 2AA",
    "city": "London",
    "district": "Westminster",
    "country_code": "GB",
    "confidence": 0.97
  }
}

You store address, postcode, and district in the ticket. confidence below 0.80 goes to a dispatcher review queue.

curl — find the nearest technician by category

curl -s "https://csv2geo.com/api/v1/places/nearby" \
  --data-urlencode "lat=51.5074" \
  --data-urlencode "lng=-0.1278" \
  --data-urlencode "radius=15000" \
  --data-urlencode "categories=hvac_commercial" \
  --data-urlencode "limit=10" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY" \
  -G

Returns the ten nearest registered places tagged hvac_commercial within 15 km, sorted by distance ascending.

Python — parallel dispatch

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

API   = "https://csv2geo.com/api/v1"
KEY   = os.environ["CSV2GEO_API_KEY"]
STALE = 300  # seconds — reject technician positions older than 5 minutes

def reverse_geocode(lat, lng):
    r = requests.get(
        f"{API}/reverse",
        params={"lat": lat, "lng": lng, "api_key": KEY},
        timeout=15,
    )
    r.raise_for_status()
    return r.json()["result"]

def nearby_technicians(lat, lng, category, radius_m=15000, limit=10):
    r = requests.get(
        f"{API}/places/nearby",
        params={
            "lat": lat,
            "lng": lng,
            "radius": radius_m,
            "categories": category,
            "limit": limit,
            "api_key": KEY,
        },
        timeout=15,
    )
    r.raise_for_status()
    return r.json()["results"]

def is_fresh(tech_record, now):
    last_seen = tech_record.get("properties", {}).get("last_seen_unix", 0)
    return (now - last_seen) < STALE

def is_available(tech_id, db_conn):
    """Your own job-management DB query."""
    cur = db_conn.cursor()
    cur.execute(
        "SELECT 1 FROM technicians WHERE id = %s AND status = 'available' LIMIT 1",
        (tech_id,),
    )
    return cur.fetchone() is not None

def assign_ticket(ticket, db_conn):
    lat, lng, category = ticket["lat"], ticket["lng"], ticket["category"]
    now = time.time()

    with ThreadPoolExecutor(max_workers=2) as pool:
        f_rev  = pool.submit(reverse_geocode, lat, lng)
        f_near = pool.submit(nearby_technicians, lat, lng, category)
        address_data = f_rev.result()
        candidates   = f_near.result()

    if address_data["confidence"] < 0.80:
        return {"status": "escalate", "reason": "low_confidence_address"}

    for candidate in candidates:
        tech_id = candidate["properties"]["tech_id"]
        if not is_fresh(candidate, now):
            continue
        if not is_available(tech_id, db_conn):
            continue
        # Commit the assignment
        eta_minutes = round(candidate["distance_m"] / 1000 / 40 * 60)  # 40 km/h avg
        return {
            "status":       "assigned",
            "tech_id":      tech_id,
            "address":      address_data["address"],
            "district":     address_data["district"],
            "eta_minutes":  eta_minutes,
        }

    return {"status": "escalate", "reason": "no_available_technician_in_radius"}

The two API calls run in parallel via ThreadPoolExecutor. On a well-provisioned host, both return within a few hundred milliseconds; the join point is whichever is slower. The rest of the function is pure Python logic over data already in memory.

Node — same pattern

const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_API_KEY;
const STALE_MS = 300_000; // 5 minutes

async function reverseGeocode(lat, lng) {
  const url = `${API}/reverse?lat=${lat}&lng=${lng}&api_key=${KEY}`;
  const r = await fetch(url);
  if (!r.ok) throw new Error(`reverse geocode http ${r.status}`);
  return (await r.json()).result;
}

async function nearbyTechnicians(lat, lng, category, radiusM = 15000, limit = 10) {
  const url =
    `${API}/places/nearby?lat=${lat}&lng=${lng}` +
    `&radius=${radiusM}&categories=${category}&limit=${limit}&api_key=${KEY}`;
  const r = await fetch(url);
  if (!r.ok) throw new Error(`places/nearby http ${r.status}`);
  return (await r.json()).results;
}

async function assignTicket(ticket, checkAvailability) {
  const { lat, lng, category } = ticket;

  const [addressData, candidates] = await Promise.all([
    reverseGeocode(lat, lng),
    nearbyTechnicians(lat, lng, category),
  ]);

  if (addressData.confidence < 0.80) {
    return { status: 'escalate', reason: 'low_confidence_address' };
  }

  const now = Date.now();
  for (const c of candidates) {
    const lastSeen = (c.properties?.last_seen_unix ?? 0) * 1000;
    if (now - lastSeen > STALE_MS) continue;
    const techId = c.properties.tech_id;
    if (!(await checkAvailability(techId))) continue;
    const etaMinutes = Math.round(c.distance_m / 1000 / 40 * 60);
    return {
      status:      'assigned',
      techId,
      address:     addressData.address,
      district:    addressData.district,
      etaMinutes,
    };
  }

  return { status: 'escalate', reason: 'no_available_technician_in_radius' };
}

Promise.all fires both requests concurrently. The checkAvailability callback is your own DB query — keep it out of the API layer so the two concerns do not bleed together.

Step-by-step production setup

Step 1: Register your technician fleet as Places

The Places by category query works because your technicians are registered as named places with category tags in CSV2GEO's location index. Use the Places write endpoint to register each technician as a place with a name (their ID or display name), a category (your skill taxonomy), and a location (their current lat/lng). Your device-heartbeat job updates the location and a last_seen_unix property every thirty seconds.

This is the most important architectural decision in the whole pattern. Do not try to do category-filtered proximity queries against your own flat SQL table inside the same synchronous HTTP handler — you will end up writing and maintaining geospatial index logic that the Places endpoint already handles for you.

Step 2: Define your category taxonomy before you write any code

Resist the temptation to use a single technician category and filter by skill in your application layer. The Places nearby endpoint sorts by distance; if you ask for all technicians and then filter by skill in Python, you are potentially discarding the ten nearest results and assigning the eleventh. Define categories that map to your dispatch lanes: hvac_residential, hvac_commercial, plumbing, electrical_low_voltage, fibre_install, and so on. One technician can have multiple categories — register them once per category if your Places layer supports multi-category tagging, or duplicate the record with a composite key.

Step 3: Set your radius and fallback chain

Start with a radius that covers your typical territory density — 10 km in an urban area, 50 km in a rural one. If the first query returns zero results, widen the radius by 50% and retry once before escalating. Log both attempts and the outcome. After two weeks of production data you will have the right radius for each district without guessing.

The retry pattern here is a deliberate widening strategy, not an error-retry. Keep it separate from your HTTP error-retry logic (exponential backoff on 5xx). See Exponential Backoff — When to Retry, When to Stop for the HTTP retry layer.

Step 4: Gate on reverse geocode confidence before assigning

A confidence score below 0.80 from /api/v1/reverse means the customer's coordinates resolved to a place the system is not certain about — a road centreline rather than a building entrance, or a postcode centroid rather than a parcel. Assigning a technician to an uncertain address wastes a trip. Route low-confidence tickets to a dispatcher review step where a human confirms the address before the assignment commits. The confidence field is there precisely for this purpose — treat it as a mandatory branch condition, not an optional annotation.

For deeper treatment of what confidence scores mean and how to act on them, Geocoding Confidence Scores Explained covers the topic end to end.

Step 5: Cache the reverse geocode, not the technician positions

Technician positions change every thirty seconds; you must never cache them. Customer addresses, on the other hand, are stable for the lifetime of the ticket — the same coordinates will always reverse geocode to the same address. Cache the reverse geocode result against the rounded lat/lng (four decimal places — roughly 11 m precision) in Redis with a TTL of one hour. On a busy day with repeat callers from the same address, this removes the reverse geocode credit entirely from the second and subsequent calls. See Caching Geocoding Results — 90% Cost Reduction for the full caching architecture.

Step 6: Instrument the assignment pipeline

Add three metrics to your APM from day one:

  • dispatch.auto_assigned — count of tickets successfully auto-assigned, by category and district
  • dispatch.escalated — count of tickets escalated, with reason as a dimension tag (no_available_technician_in_radius, low_confidence_address, stale_positions)
  • dispatch.eta_error_minutes — difference between eta_minutes at assignment time and actual_arrival_minutes at job completion

The third metric is your accuracy signal. If your ETA model (distance ÷ average speed) is consistently wrong by the same sign — always over, always under — you can correct the average speed constant per district using the historical data. Start collecting it from the first ticket; you cannot retrofit it later without replaying history. See Observability for Geocoding Pipelines for the full metric taxonomy.

Failure modes to handle explicitly

The technician pool is empty. Your Places layer is only as fresh as your heartbeat job. If the heartbeat job has a bug or the device has no signal, the nearest technician query returns an empty list — not an error. The assignment code treats empty-list as an escalation. Build the escalation path before you build the happy path; otherwise your first production failure is a silent queue backup.

The reverse geocode returns a road, not a building. In low-density areas, coordinates sometimes snap to a road centreline rather than a building. The address string will look real but will be off by 20-40 metres. Your technician will stand at the roadside wondering where the customer is. Mitigate this by surfacing the confidence score in the ticket UI next to the address — a dispatcher reviewing a confidence: 0.75 ticket knows to call the customer and confirm the exact location before dispatching.

SLA zones and district boundaries disagree. Your SLA contract likely defines zones by postcode prefix, not by the exact district boundaries that the reverse geocode returns. Build a small lookup table that maps districtsla_zone and keep it in your application layer. Do not assume the API's district field exactly matches your contractual zone definitions; it will not, and the mismatch will appear in your first SLA audit.

Concurrency spikes on major fault events. A single fibre cut or a regional power outage creates a burst of hundreds of tickets in seconds — all near-identical coordinates, all requesting the same category. The Places nearby endpoint handles the query load, but your DB availability check will bottleneck. Pre-cache technician availability states in Redis (SETEX tech:$id:available 60 1) updated by the job-management system on every status change, so the availability check is a Redis GET rather than a SQL row lock under spike. Concurrency Tuning for Geocoding Pipelines covers the broader thread-pool and connection-pool tuning for this scenario.

Cost model for a real field-service operation

A team running 5,000 tickets per day across a regional territory:

  • 5,000 reverse geocode calls per day at 1 credit each = 5,000 credits
  • 5,000 Places nearby calls per day at 1 credit each = 5,000 credits
  • Reverse geocode cache hit rate of ~40% on repeat addresses (same buildings call frequently) = saves ~2,000 credits/day
  • Net: ~8,000 credits/day, ~240,000/month

At paid pricing starting from $54/month for 100,000 calls, a 5,000-ticket-per-day operation sits comfortably in the mid-tier bracket. The free tier (3,000 calls/day, no credit card) covers a pilot of roughly 1,500 tickets per day — enough to validate the pattern end to end before committing. See the live tier breakdown at csv2geo.com/pricing/api.

The dispatch-console post covers a 5,000-stop-per-day real-world scenario in more operational detail — Dispatch Console: 5,000 Stops Per Day is worth reading alongside this one.

What this does not replace

Honest scope.

Route optimisation. Nearest technician is not the same as optimal route through ten tickets. This pattern assigns one ticket to one technician based on proximity and skill. Sequence optimisation across a full day's workload requires a separate TSP/VRP layer — the geocoding API gives you the coordinates; the optimisation is your problem or a dedicated routing engine's problem.

Real-time traffic. The ETA estimate above uses distance ÷ average speed. It does not account for traffic, road closures, or ferry crossings. For an SLA-sensitive operation, pipe the assigned technician's lat/lng and the customer's lat/lng into a routing API to get a traffic-aware duration. That is a separate call to a separate system; the pattern here is agnostic to which routing engine you use.

Workforce management. Availability in this pattern means "not currently on a job." It does not account for shift end in forty minutes, a mandatory lunch break, a pending parts delivery, or a training block in the calendar. Your job-management system owns those rules; the dispatch pattern queries whatever status = 'available' means in your schema.

Frequently Asked Questions

What if two tickets are assigned simultaneously and both try to claim the same technician?

Use an optimistic lock on the technician availability write. A simple UPDATE technicians SET status='assigned', ticket_id=$new_ticket WHERE id=$tech_id AND status='available' returning the updated row count tells you whether you won the race. If the count is zero, re-run the assignment query for the next candidate. This is the same pattern as an inventory reservation; it is well-understood and does not require a queue or a saga — for typical field-service ticket rates, a single retry resolves the conflict.

Can I use this pattern without registering technicians as Places in CSV2GEO?

Yes, but then the "find nearest" query runs against your own database and you need to implement your own geospatial index. The reverse geocode call still adds value — clean address, district code, confidence — but the Places nearby endpoint is only useful if your technician locations are registered there. The trade-off is: a few minutes of setup to register positions via the write API, versus maintaining a PostGIS or SQLite-spatialite index yourself.

How do I handle technicians who are driving and updating position every 30 seconds?

Update their registered Place position via the write API on every heartbeat from the device. The Places nearby query always reads the current registered position. The freshness gate (last_seen_unix check in the code above) handles the case where heartbeats have stopped — a technician who has not sent a position update in five minutes is excluded from candidates regardless of how close their last known position was.

Is the Places nearby query accurate enough to distinguish technicians on different floors of the same building?

No. The geocoding stack operates at street-address precision — typically 5-15 m horizontal accuracy for urban areas. Floor-level vertical precision requires indoor positioning systems (Bluetooth beacons, UWB, Wi-Fi triangulation) that are outside the scope of a geocoding API. For field-service dispatch, street-address precision is sufficient; you do not need to know whether the technician is on floor 3 or floor 4 of a depot.

What countries does this pattern work in?

Reverse geocoding covers 39 countries across our 461M+ address dataset. The Places nearby endpoint covers the same footprint. If your operation spans territories outside the supported country list, the reverse geocode returns a lower-confidence result or a partial address — run the confidence gate and escalate low-confidence tickets rather than trusting a partial result. Check the current country list on the product page before planning an international rollout.

Do the SDKs support this pattern?

Python and Node SDKs are available and will work fine for this use case. The REST calls above are intentionally written without SDK dependencies so you can inspect exactly what is on the wire and integrate into any HTTP-capable runtime — Go, Ruby, Java, PHP — without waiting for an SDK release. The SDKs wrap the same endpoints; there is no capability available in the SDK that is not available in the REST interface.

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 →