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-installnot"AC installation (residential)". The optimizer treats them as opaque strings. - Be precise:
ac-install-residentialvsac-install-commercialmay 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 pattern | Why |
|---|---|
"Diagnose problem" + comments carrying the customer's symptoms | First thing the tech does on arrival. |
"Replace part X" + barcode of the SKU | Tech scans the part as proof it was installed. |
| "Test pressure / continuity / signal" | Functional verification step. |
"Sign work order" + comments carrying the form template | Captures 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 viacustom_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_idProduction hardening
Defining the skill catalog
Skill strings have to be stable and shared between your source system and Routal. The two patterns that work:
- 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). - 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-textcomments,cancel_reason. - One per stop completion, attempt, or cancellation.
- Fires the
routal.planner.2.stop.reportedwebhook.
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[].urlandsignature.urlto 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
providedby 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:
- Suggest an alternate day to the customer. Move the ticket's preferred date by one day in your source system.
- Add an on-call technician for that day. The integration's response to
no_result_foundshould page the supervisor with a list of unfittable appointments + the missing skills. - 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 finalstatus(completed/canceled) and optionalcommentsfilled 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→ usePOST /v2/stop/moveto move it to the new day's plan. Theexternal_id(your ticket number) stays the same; everything else carries over. - Stop was already attempted / failed → create a new stop with a new
external_idderived 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
| Metric | What 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_total | requires strings that no technician provides — typically a typo or a new ticket type that hasn't been mirrored. |
field_service.no_result_found_rate | Days 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_min | Minutes 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_id | Cause | What to do |
|---|---|---|
highway.stop.error.custom_fields_invalid | Stop 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_found | No combination of skills + windows fits all appointments. | Page the supervisor; resolve with one of the recovery patterns above. |
highway.task.error.not_found | PUT /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/task | Required 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_stops | Tried 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
- Capacitated distribution
— if your technicians also carry inventory (spare parts as
requires/ van asweight/volume), combine this recipe with capacity constraints. - Idempotency — reschedule patterns and how to identify a retry vs. a genuine new appointment.
- API Reference →
POST /v2/task— the exact task body schema.
