Skip to main content
Routal

Field service with appointments

For mobile workforces — technicians, installers, home health, equipment maintenance. The unit of work is a service appointment, not a delivery. This recipe covers skill matching, tight appointment windows, multi-task checklists, and how to keep the customer-facing ticket in sync with what the technician did on site.

Who this is for

You run a mobile workforce — technicians, installers, home-care workers, equipment maintenance crews — and your unit of work is a scheduled appointment, not a delivery. Common shapes:

  • Telecom installers connecting new fibre subscribers.
  • HVAC, plumbing, or electrical repair calls.
  • Home health visits to elderly or post-surgery patients.
  • Field maintenance of utility meters, ATMs, vending machines, sanitation equipment.
  • White-glove furniture or appliance installation.

What makes you different from a B2B delivery operation:

  • The time window is a customer appointment ("between 10am and noon"), not a delivery slot. A driver running 30 minutes late on the previous stop affects the next customer's morning, not just their porch.
  • The work itself takes a meaningful amount of time (15 minutes to 3 hours), so on-site duration is the dominant factor in routing.
  • Your technicians have specializations. An A/C specialist can't fix a boiler. A nurse with paediatric certification can't do adult home health visits in some jurisdictions.
  • The proof of work is structured: signed work order, photos of the installation, scanned barcodes of parts used, an explicit checklist of what was done.

If that sounds like your operation, this recipe walks through the four moving parts that distinguish field service from delivery: skill matching, appointment windows, multi-task checklists, and ticket-to-stop mapping.

What the rhythm looks like

Day -2 to -1                            Day 0 (execution)
─────────────────────────────────────   ─────────────────────────────────────
Customer books an appointment            08:00  Technicians dispatched
through your scheduling tool                    → routes flip to in_transit
  → window confirmed
                                         09:30  Tech arrives at first appointment
                                                → opens the stop in the app
                                                → ticks tasks one by one
                                                → captures signature + photo
                                                → submits the report

Day -1 evening                           11:00  Next appointment
17:00  Scheduling tool closes books     
       for tomorrow's appointments      13:00  Lunch break (vehicle break window)
                                        
17:30  ⚡ Daily plan job                 14:30  Afternoon appointments
       1. Create tomorrow's plan        
       2. Bulk-add appointments         
          with requires, time_windows,
          duration, tasks               18:00  Last appointment
       3. Run the optimizer                    → routes finish
       4. Dispatch each technician             → tickets close in source system
                                        
21:00  Supervisor reviews,              
       adjusts manually if needed       

The daily plan job is essentially the nightly batch B2B pattern — the differences are in the stop payload (the skills, the narrow window, the tasks) and in the webhook handling (the report carries structured task results that have to flow back into the customer's ticket).

The four moving parts

1. Skill matching — requires on stops, provides on vehicles

Each technician (modelled as a Vehicle) declares default_provides — a list of strings representing what they can do. Each appointment (modelled as a Stop) declares requires — a list of strings the assigned route must cover.

The optimizer will not assign a stop to a route that doesn't cover its requires. There is no partial match, no warning — it's hard exclusion.

// vehicle (technician's van)
{
  "external_id": "TECH-Maria-AC",
  "label": "María — A/C senior",
  "default_provides": ["ac-install", "ac-repair", "ac-maintenance", "language-en"]
}

// stop (appointment)
{
  "external_id": "TICKET-44521",
  "label": "Customer A — A/C install",
  "requires": ["ac-install", "language-en"]
}

Convention tips:

  • Use stable string identifiers, not human labels. ac-install not "AC installation (residential)". The optimizer treats them as opaque strings.
  • Be precise: ac-install-residential vs ac-install-commercial may matter for routing and definitely matters for SLA and pricing.
  • Stable enums map to your source system: technician certifications and ticket types live there; Routal just reflects them.

2. Appointment windows — narrow time_windows, not delivery slots

A B2B delivery to a restaurant has a window like "between 08:00 and 13:00". A telecom installation has a window like "10:00 to 12:00". Narrower means:

  • The optimizer has less slack, and the same number of appointments needs more technicians.
  • Buffer matters more. A 90-minute appointment scheduled in a 120-minute window leaves 30 minutes for transit + slip. Less than that and a single delay cascades to every later stop.
  • time_windows[] is an array — you can offer the customer two windows ("morning OR afternoon"), and the optimizer picks the best one.
{
  "external_id": "TICKET-44521",
  "label": "Boiler install — Mr. Pérez",
  "time_windows": [
    [36000, 39600]   // 10:00–11:00 (single offered slot)
  ],
  "duration": 5400   // 90 minutes of on-site work
}

3. Multi-task checklists — POST /v2/task

Once a stop exists, attach tasks — labelled checklist items the technician ticks one by one in the app:

Task patternWhy
"Diagnose problem" + comments carrying the customer's symptomsFirst thing the tech does on arrival.
"Replace part X" + barcode of the SKUTech scans the part as proof it was installed.
"Test pressure / continuity / signal"Functional verification step.
"Sign work order" + comments carrying the form templateCaptures customer acknowledgement.

Tasks are different from a report: a report is the free-form POD (signature image, photos, notes) the tech submits at the end of the visit, and it triggers the routal.planner.2.stop.reported webhook. Tasks are the structured checklist tracked separately and persisted on the stop.

4. Ticket-to-stop mapping — external_id carrying your ticket number

Your source system owns the ticket (job number, work order, customer call reference). Routal owns the stop. The bridge is external_id:

  • Stops get external_id: "TICKET-44521".
  • Vehicles get external_id: "TECH-Maria-AC".
  • Tasks reference your SKU via barcode, and your form fields via custom_fields.

When the stop reports (driver completes / fails / cancels), the webhook payload includes data.stop_id and you GET /v2/stop/{stop_id} to fetch the external_id → join back to your ticket.

Quickstart

The Quickstart below creates a single appointment, attaches three tasks, and shows the dispatch path. For the full daily-plan job that creates many appointments at once, use the same skeleton as nightly batch B2B but with the field-service stop shape from this page.

# (1) Create the appointment on tomorrow's plan. requires + time_windows are
#     the field-service-specific bits.
curl -X POST "https://api.routal.com/v2/stops?private_key=YOUR_KEY&plan_id=PLAN_ID&project_id=PROJECT_ID" \
  -H 'Content-Type: application/json' \
  -d '[
    {
      "external_id": "TICKET-44521",
      "label": "Mr. Pérez — Boiler repair",
      "location": { "lat": 41.20, "lng": 2.05, "address": "Customer address line" },
      "duration": 5400,
      "time_windows": [[36000, 39600]],
      "requires": ["plumbing", "boiler-emergency"],
      "phone": "+10000044521",
      "custom_fields": {
        "ticket_id": "44521",
        "customer_segment": "premium"
      }
    }
  ]'
# → returns the created stop. Save the stop_id.

# (2) Attach checklist tasks. One POST per task.
curl -X POST "https://api.routal.com/v2/task?private_key=YOUR_KEY&stop_id=STOP_ID" \
  -H 'Content-Type: application/json' \
  -d '{
    "label": "Diagnose problem",
    "comments": "Customer reports no hot water since this morning."
  }'

curl -X POST "https://api.routal.com/v2/task?private_key=YOUR_KEY&stop_id=STOP_ID" \
  -H 'Content-Type: application/json' \
  -d '{
    "label": "Replace ignition module",
    "barcode": "PART-IGN-7715",
    "comments": "Part to be scanned from the technician van."
  }'

curl -X POST "https://api.routal.com/v2/task?private_key=YOUR_KEY&stop_id=STOP_ID" \
  -H 'Content-Type: application/json' \
  -d '{
    "label": "Verify pressure",
    "comments": "Hold for 60s at 1.5 bar."
  }'

# (3) From here, optimize the plan + dispatch as usual.
curl -X POST "https://api.routal.com/v2/plan/PLAN_ID/optimize?private_key=YOUR_KEY"
curl -X POST "https://api.routal.com/v2/route/ROUTE_ID/dispatch?private_key=YOUR_KEY"
import createClient from 'openapi-fetch';
import type { paths } from './routal';

const routal = createClient<paths>({ baseUrl: 'https://api.routal.com' });
const ROUTAL_API_KEY = process.env.ROUTAL_API_KEY!;
const PROJECT_ID = process.env.ROUTAL_PROJECT_ID!;

type Appointment = {
  ticketId: string;
  customerLabel: string;
  lat: number;
  lng: number;
  address?: string;
  phone?: string;
  windowFromSec: number;
  windowToSec: number;
  serviceMinutes: number;
  requiredSkills: string[];        // e.g. ['plumbing', 'boiler-emergency']
  customFields?: Record<string, string>;
  checklist: Array<{
    label: string;
    comments?: string;
    barcode?: string;
  }>;
};

/**
 * Create an appointment as a stop with its task checklist attached.
 * Returns the stop_id so dispatch / reporting flows can resolve back to the ticket.
 */
export async function createAppointment(
  planId: string,
  appt: Appointment,
): Promise<string> {
  // (1) Create the stop.
  const { data, error } = await routal.POST('/v2/stops', {
    params: {
      query: { private_key: ROUTAL_API_KEY, plan_id: planId, project_id: PROJECT_ID },
    },
    body: [
      {
        external_id: appt.ticketId,
        label: appt.customerLabel,
        location: { lat: appt.lat, lng: appt.lng, address: appt.address },
        duration: appt.serviceMinutes * 60,
        time_windows: [[appt.windowFromSec, appt.windowToSec]],
        requires: appt.requiredSkills,
        phone: appt.phone,
        custom_fields: appt.customFields,
      },
    ] as never,
  });
  if (error) throw error;
  const stopId = (data as { id: string }[])[0].id;

  // (2) Attach the checklist. One call per task — Routal serializes per stop.
  for (const item of appt.checklist) {
    const { error: taskErr } = await routal.POST('/v2/task', {
      params: { query: { private_key: ROUTAL_API_KEY, stop_id: stopId } },
      body: {
        label: item.label,
        comments: item.comments,
        barcode: item.barcode,
      } as never,
    });
    if (taskErr) throw taskErr;
  }

  return stopId;
}
import os
import requests

ROUTAL_API_KEY = os.environ["ROUTAL_API_KEY"]
PROJECT_ID = os.environ["ROUTAL_PROJECT_ID"]
BASE = "https://api.routal.com"


def create_appointment(plan_id: str, appt: dict) -> str:
    """
    Create an appointment as a stop with its task checklist attached.
    Returns the stop_id.
    """
    payload = [{
        "external_id": appt["ticket_id"],
        "label": appt["customer_label"],
        "location": {
            "lat": appt["lat"],
            "lng": appt["lng"],
            "address": appt.get("address"),
        },
        "duration": appt["service_minutes"] * 60,
        "time_windows": [[appt["window_from_sec"], appt["window_to_sec"]]],
        "requires": appt["required_skills"],
        "phone": appt.get("phone"),
        "custom_fields": appt.get("custom_fields"),
    }]
    resp = requests.post(
        f"{BASE}/v2/stops",
        params={"private_key": ROUTAL_API_KEY, "plan_id": plan_id,
                "project_id": PROJECT_ID},
        json=payload,
        timeout=30,
    )
    resp.raise_for_status()
    stop_id = resp.json()[0]["id"]

    for item in appt["checklist"]:
        task_resp = requests.post(
            f"{BASE}/v2/task",
            params={"private_key": ROUTAL_API_KEY, "stop_id": stop_id},
            json={
                "label": item["label"],
                "comments": item.get("comments"),
                "barcode": item.get("barcode"),
            },
            timeout=30,
        )
        task_resp.raise_for_status()

    return stop_id

Production hardening

Defining the skill catalog

Skill strings have to be stable and shared between your source system and Routal. The two patterns that work:

  1. Define the catalog in your source system. Your dispatch software / ticketing tool already knows what certifications a technician has and what ticket types exist. Mirror those strings into Routal at vehicle creation time (default_provides) and stop creation time (requires).
  2. Document a fixed enumeration in a shared registry. A markdown file or a tiny config service that lists every legal skill string. Both teams read from it.

Mixing the two — letting individual technicians declare custom skills in the field — produces a graveyard of typos (acrepair, ac-repair, AC repair) and routes that look fine until the day the optimizer matches them to a stop that misspells the same word.

Custom fields — define them in the dashboard first

Custom fields must be declared in the project settings (planner.routal.com) before you can use them on stops, routes, or vehicles. The dashboard accepts strings, numbers, booleans, dates, and dropdowns. Once defined, reference them by name from the API:

{
  "external_id": "TICKET-44521",
  "custom_fields": {
    "ticket_id": "44521",
    "ticket_priority": "P2",        // dropdown
    "customer_id": "CUST-991",
    "follow_up_required": true      // boolean
  }
}

If you POST a custom field that isn't defined in the project, you get highway.stop.error.custom_fields_invalid and the whole stop fails. Set up the schema once, version it like code in your source repo, and let the integration mirror it.

Tasks vs. report — pick the right tool

Tasks (this recipe) are structured checklist items the technician ticks in the app:

  • Each task has a status (pending / completed / canceled).
  • Tasks persist with the stop.
  • Best for SKU scanning, multi-step procedures, and items where you need a per-line outcome ("part X was installed", "calibration passed").

Reports (delivered via the stop.reported webhook) are the free-form POD at the end of the visit:

  • Carries images, signature, free-text comments, cancel_reason.
  • One per stop completion, attempt, or cancellation.
  • Fires the routal.planner.2.stop.reported webhook.

A typical field service stop produces both: the technician ticks tasks during the visit, then submits a report at the end (with the customer's signature, photos of the completed work, optional notes).

When designing the data flow:

  • Your ticket status in the source system should come from the report (completed / incomplete / canceled).
  • Your per-line outcomes (parts installed, tests passed) should come from the task statuses in the same payload.
  • Your photos and signature should be downloaded from the report's images[].url and signature.url to your own storage during webhook processing.

Sequence within an appointment

The driver app shows the task list in the order they were created. If sequence matters ("scan in before you scan out"), create tasks in that order. There is no position field on tasks today — re-ordering after creation requires deleting and recreating.

The "no available technician" scenario

POST /v2/plan/{id}/optimize returns highway.optimization.error.no_result_found when:

  • The skills required by some appointment aren't provided by any technician scheduled for the day.
  • The total appointment minutes plus driving time exceed the technicians' combined working windows.

For field service this is more common than for delivery — narrow windows and required skills shrink the feasible set sharply. Recovery patterns:

  1. Suggest an alternate day to the customer. Move the ticket's preferred date by one day in your source system.
  2. Add an on-call technician for that day. The integration's response to no_result_found should page the supervisor with a list of unfittable appointments + the missing skills.
  3. Relax the skill requirement (with the dispatcher's explicit consent — not automatically). A general-purpose technician can sometimes cover a specialist appointment in a pinch.

Webhook handling — task statuses arrive in the report

When the technician submits at the end of the visit, the routal.planner.2.stop.reported webhook carries:

  • data.tasks[] — each task with its final status (completed / canceled) and optional comments filled in by the technician.
  • data.signature.url / data.images[].url — the proof-of-work assets.
  • data.type — overall outcome (service_report_completed / service_report_attempted / service_report_canceled).

The integration walks data.tasks[] against the original checklist and writes back to your ticket. Skipping a task (status remains pending after the report) usually means the technician deemed it unnecessary or impossible — your ticket logic decides whether that's acceptable.

Reschedule, don't recreate

When a customer asks to reschedule:

  • Stop is still pending → use POST /v2/stop/move to move it to the new day's plan. The external_id (your ticket number) stays the same; everything else carries over.
  • Stop was already attempted / failed → create a new stop with a new external_id derived from the same ticket (TICKET-44521-RESCHED-1). Moving a terminal stop rewrites delivery history and breaks the technician's performance metrics for the original day.

Observability — the metrics that matter

MetricWhat it tells you
field_service.appointments_created_total{required_skill}Demand by skill. Spot which certifications you need to hire more of.
field_service.unmatched_appointments_totalrequires strings that no technician provides — typically a typo or a new ticket type that hasn't been mirrored.
field_service.no_result_found_rateDays where the optimizer couldn't fit every appointment. Spike means understaffing or windows too narrow.
field_service.task_skip_rate{task_label}Tasks the technician marks canceled rather than completed. Investigate the top one — usually a process problem.
field_service.stop_report_latency_minMinutes between appointment end and report submission. A long tail means technicians are submitting in batches; encourage app submission on site.

Tag every appointment with the source-system ticket id; the dispatcher always needs to grep "where did ticket 44521 end up?".

Common errors

message_idCauseWhat to do
highway.stop.error.custom_fields_invalidStop sent custom_fields with a key that isn't defined in the project.Define the field in the dashboard. Don't try to create field definitions from the API — set them up once and version.
highway.optimization.error.no_result_foundNo combination of skills + windows fits all appointments.Page the supervisor; resolve with one of the recovery patterns above.
highway.task.error.not_foundPUT /v2/task/{task_id} or DELETE /v2/task/{task_id} referenced a task that's been removed (or never existed).Read the stop's current task list before mutating.
400 Bad Request (no message_id) on POST /v2/taskRequired field missing (label) or a barcode that doesn't fit the expected length.Read the response message, fix the payload.
highway.stop.error.move_not_pending_stopsTried to reschedule a stop after it was attempted / failed / cancelled.Create a new stop with a new external_id derived from the same ticket.

Next steps