Recurring services — calendar-driven dispatch
Operations that run on a calendar, not an order book. Bulk gas refills, ITV inspections, elevator maintenance, water cooler service, pest control. The same customer is visited every N weeks at roughly the same time slot, and the value the customer pays for is the reliability of the schedule. This recipe covers schedule-to-Routal materialisation, customer-stable time slots, and the deviation patterns that keep recurring routes honest.
Who this is for
You operate a fleet where the schedule, not the order book, drives the day. Common shapes:
- A butane / propane gas distributor swapping bottles on a quarterly rotation. Customer expects the truck to arrive within their contracted window every 90 days, full stop.
- A vehicle inspection (ITV in Spain, MOT in the UK, TÜV in Germany) service that books recurring inspection slots for fleet customers — same garage, same time, same week of the month, year after year.
- An elevator / lift maintenance company on a strict regulatory cadence — monthly inspections per cabin, missing one creates a compliance liability.
- A water-cooler / coffee-machine vendor topping up consumables and doing preventive maintenance every 4-6 weeks per location.
- A pest control route servicing restaurants, hotels, supermarkets on a bi-weekly visit cycle to stay ahead of infestation.
What makes you different from event-driven operations:
- Visits are scheduled weeks or months ahead. The integration doesn't react to an order arriving today; it materialises a known future schedule into Routal day-by-day.
- The customer is paying for the schedule. If you miss a contracted visit or shift it by an hour, you've broken the product. The day's plan is much closer to a fixed itinerary than an optimization target.
- Same customer, same slot, every cycle. Loyalty and predictability matter more than density. The optimizer is mostly arranging visits inside the constraints set by the customer's contract.
What the rhythm looks like
Calendar in the FSM → daily materialisation → execution:
T-N weeks T-1 day T (visit day)
───────────────────── ────────────── ─────────────────────
FSM holds the recurring 17:00 Nightly 06:00 Drivers open
schedule. For each contract: job fires the app
- customer
- service type 1. Read FSM for 08:00-17:00 Visits
- skill required visits due run on contracted
- cadence (every N weeks) tomorrow slots
- contracted window 2. For each visit: → stop.reported
(e.g. Mon 09:00-11:00) - generate → bumps next_visit_at
- next_visit_at external_id = in FSM by cadence
VISIT-<id>-<date>
- push as stop 18:00 Daily reconciliation
- chain_id when - any stop with
multiple visits service_report_incomplete
at same location → FSM creates a make-up
visit within SLA
3. Optimize
4. DispatchTwo notes specific to recurring services:
- The external_id is a deterministic function of (customer, scheduledDate). This makes the nightly push idempotent — re-running it generates the same IDs, so duplicates are impossible.
time_windowsare narrow. A visit contracted for "Monday 9-11am" is pushed withtime_windows: [[32400, 39600]]. The optimizer respects it as a hard constraint; if the day's fleet can't fit all narrow-window stops,no_result_foundfires — investigate the schedule density, don't auto-relax.
The integration shape
FSM (recurring schedule database)
│
│ nightly cron — read visits due in look-ahead window
▼
[ visits: { customer_id, scheduled_date, contracted_slot, skill, location } ]
│
│ (1) generate deterministic external_ids
▼
[ stops: { external_id: 'VISIT-<customer>-<date>', time_windows, requires, ... } ]
│
│ (2) push to tomorrow's plan
▼
POST /v2/stops (idempotent: re-runs = no-op)
│
│ (3) optimize once
▼
POST /v2/plan/{id}/optimize
│
│ (4) dispatch every route
▼
POST /v2/route/{id}/dispatch
│
│ (5) webhooks during execution
▼
stop.reported → FSM: bump next_visit_at, attach POD, close service ticket
if service_report_incomplete: FSM schedules a make-upThe single biggest difference vs. the other recipes: the source of truth for the day's stops is not an order book, it's a calendar plus cadence rule per customer. Your integration's first job is to walk the calendar and generate visits; the second is to push them to Routal.
Production code
# (1) Push tomorrow's calendar-derived visits as stops. Each carries a
# narrow window matching the customer's contracted slot and a skill
# requirement matching the service type.
curl -X POST "https://api.routal.com/v2/stops?private_key=YOUR_KEY&plan_id=PLAN_ID&project_id=YOUR_PROJECT_ID" \
-H 'Content-Type: application/json' \
-d '[
{
"external_id": "VISIT-CUST-0042-2026-05-22",
"label": "Acme Café — quarterly butane refill",
"location": { "lat": 41.3851, "lng": 2.1734, "address": "Carrer Aragó 215, 08010 Barcelona" },
"duration": 900,
"time_windows": [[32400, 39600]],
"phone": "+34699445566",
"requires": ["butane-licensed"],
"tasks": [
{ "type": "scan", "label": "Scan returned cylinder serials" },
{ "type": "photo", "label": "Photo of installed cylinders" },
{ "type": "signature", "label": "Customer signature on receipt" }
]
}
]'
# (2) Optimize.
curl -X POST "https://api.routal.com/v2/plan/PLAN_ID/optimize?private_key=YOUR_KEY"
# (3) Dispatch.
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 Visit = {
customerId: string;
scheduledDate: string; // 'YYYY-MM-DD' in the project's local TZ
customerName: string;
lat: number;
lng: number;
address: string;
phoneE164: string;
slotFromSec: number; // contracted window start (seconds from midnight)
slotToSec: number;
serviceDurationSec: number;
skillRequired: string; // e.g. 'butane-licensed'
};
/** Deterministic external_id makes the nightly push idempotent. */
function visitExternalId(visit: Visit): string {
return `VISIT-${visit.customerId}-${visit.scheduledDate}`;
}
export async function materialiseDay(planId: string, visits: Visit[]) {
if (visits.length === 0) return { pushed: 0 };
const payload = visits.map((v) => ({
external_id: visitExternalId(v),
label: `${v.customerName} — ${v.scheduledDate}`,
location: { lat: v.lat, lng: v.lng, address: v.address },
duration: v.serviceDurationSec,
time_windows: [[v.slotFromSec, v.slotToSec]] as [number, number][],
phone: v.phoneE164,
requires: [v.skillRequired],
tasks: [
{ type: 'scan', label: 'Scan equipment serials' },
{ type: 'photo', label: 'Service-completed photo' },
{ type: 'signature', label: 'Customer signature' },
],
}));
for (let i = 0; i < payload.length; i += 250) {
const chunk = payload.slice(i, i + 250);
const { error } = await routal.POST('/v2/stops', {
params: { query: { private_key: ROUTAL_API_KEY, plan_id: planId, project_id: PROJECT_ID } },
body: chunk as never,
});
if (error) throw error;
}
return { pushed: payload.length };
}
/** On stop.reported, advance the FSM's next_visit_at by the contract cadence. */
export async function onVisitReported(
externalId: string,
reportType: 'service_report_completed' | 'service_report_incomplete' | 'service_report_canceled',
fsm: { bumpNextVisit: (extId: string) => Promise<void>; scheduleMakeUp: (extId: string) => Promise<void> },
) {
if (reportType === 'service_report_completed') {
await fsm.bumpNextVisit(externalId);
} else {
// incomplete or canceled — schedule a make-up visit within SLA.
await fsm.scheduleMakeUp(externalId);
}
}import os, requests
ROUTAL_API_KEY = os.environ["ROUTAL_API_KEY"]
PROJECT_ID = os.environ["ROUTAL_PROJECT_ID"]
BASE = "https://api.routal.com"
def visit_external_id(visit: dict) -> str:
return f"VISIT-{visit['customer_id']}-{visit['scheduled_date']}"
def materialise_day(plan_id: str, visits: list[dict]) -> dict:
if not visits:
return {"pushed": 0}
payload = [
{
"external_id": visit_external_id(v),
"label": f"{v['customer_name']} — {v['scheduled_date']}",
"location": {"lat": v["lat"], "lng": v["lng"], "address": v["address"]},
"duration": v["service_duration_sec"],
"time_windows": [[v["slot_from_sec"], v["slot_to_sec"]]],
"phone": v["phone"],
"requires": [v["skill_required"]],
"tasks": [
{"type": "scan", "label": "Scan equipment serials"},
{"type": "photo", "label": "Service-completed photo"},
{"type": "signature", "label": "Customer signature"},
],
}
for v in visits
]
for i in range(0, len(payload), 250):
chunk = payload[i:i + 250]
requests.post(
f"{BASE}/v2/stops",
params={"private_key": ROUTAL_API_KEY, "plan_id": plan_id, "project_id": PROJECT_ID},
json=chunk,
timeout=60,
).raise_for_status()
return {"pushed": len(payload)}Production hardening
Deterministic external_id — the load-bearing idempotency lever
For event-driven flows, idempotency is usually about handling duplicate webhooks. For recurring services, idempotency is about safe cron retries.
A visit is uniquely identified by (customer_id, scheduled_date). Use that
pair as the external_id:
external_id = `VISIT-${customerId}-${scheduledDate}`Re-running the nightly materialisation must produce identical IDs. Routal's
storage doesn't enforce uniqueness on external_id, but with deterministic
generation + a search-before-create check on your side, duplicates become
impossible:
async function materialiseVisitIdempotent(planId: string, visit: Visit) {
const extId = visitExternalId(visit);
const { data } = await routal.POST('/v2/stops/search', {
params: { query: { private_key: ROUTAL_API_KEY, project_id: PROJECT_ID } },
body: {
limit: 1,
predicates: [
{ field: 'external_id', operator: 'eq', value: extId, type: 'string' },
],
} as never,
});
if ((data as { docs?: unknown[] })?.docs?.length) return { skipped: true };
await materialiseDay(planId, [visit]);
return { skipped: false };
}Skill matching — the technician fit per service type
Recurring services nearly always involve a technical skill (gas certification,
elevator licensing, ITV authorisation, pesticide handling). The optimizer
respects skill matching via requires (on the stop) and default_provides
(on the vehicle / technician).
// technician vehicle
{ default_provides: ['gas-licensed', 'butane-licensed', 'propane-licensed'] }
// visit stop
{ requires: ['butane-licensed'] }Maintain the skill catalog in your FSM and mirror it into Routal vehicles
nightly (or whenever certifications change). A visit pushed with a requires
no active vehicle satisfies returns no_result_found — the same error you'd
get from a capacity overflow, with a different root cause.
Missed visit handling — make-up SLA per contract
stop.reported carries one of three types. Map them into the FSM:
service_report_completed— bumpnext_visit_atby the contract cadence (e.g., 28 days), close the service record.service_report_incomplete— driver tried but couldn't complete (customer not home, equipment unavailable, access denied). Create a make-up record in the FSM within the contract SLA (typically 1-3 business days).service_report_canceled— customer cancelled (or the office did). Bumpnext_visit_atby the cadence as if completed, but don't bill.
Each contract usually has a make-up SLA field (1 day for compliance-bound services like elevators, 3-5 days for grooming-style services like pest control). The FSM enforces it.
Look-ahead window — pick a horizon and stick to it
Generating too few days at once = more pushes, more chance of cron failures shifting the plan window. Too many days = lots of stops in distant-future plans that may need re-adjustment if customers reschedule.
Pragmatic defaults:
- Daily push, T+1 horizon. Simple, robust, but every reschedule means a customer-service human in the loop.
- Weekly push, T+7 horizon. Most common. Schedule for the whole week on
Sunday night; customer reschedules trigger a targeted
stop.move/stop.delete+ re-create on the affected day's plan. - Monthly push, T+30 horizon. Only for very stable contracts (regulatory inspections, multi-year service agreements). Reschedules are rare and manual.
Time-window enforcement — narrow is the product
A customer paying for "Monday morning service" doesn't accept the technician
arriving Tuesday afternoon. The integration must push narrow time_windows
that match the contracted slot. If the optimizer can't fit them all, the
right behaviour is to escalate, not to relax.
function visitTimeWindow(visit: Visit): [number, number][] {
// 30-minute buffer around the contracted slot for travel + greeting.
return [[visit.slotFromSec, visit.slotToSec + 1800]];
}If a particular customer is flexible ("any time on Monday"), encode it
explicitly: time_windows: [[0, 86400]]. Don't widen everyone to "8am-6pm"
by default — that erodes the schedule discipline that's your product.
Observability — the metrics that matter
| Metric | What it tells you |
|---|---|
recurring.visits_materialised_total{date} | Visits pushed for a given execution date. Match expected = good. |
recurring.missed_visits_total{reason} | Incomplete + canceled visits, grouped by cancel_reason. Tells you if churn is technician-side, customer-side, or operations-side. |
recurring.make_up_sla_breaches_total | Make-up visits not scheduled within the contract SLA. Goes to compliance dashboard. |
recurring.window_breach_rate | Stops where the driver arrived outside the contracted slot. Headline customer-satisfaction signal. |
recurring.no_result_found_rate{day} | Optimizer failures. Sustained > 2% = schedule density exceeds fleet capacity. |
Common errors
message_id | Cause | What to do |
|---|---|---|
highway.optimization.error.no_result_found | Schedule density exceeds fleet capacity, OR required skill unavailable on any active vehicle. | Either scale the fleet for that day, defer flexible visits, or escalate compliance-bound visits to a supervisor. |
highway.stop.error.move_not_pending_stops | Tried to reschedule a visit that was already attempted. | The visit already happened; create a make-up via the FSM make-up workflow, don't move. |
highway.stop.error.custom_fields_invalid | Custom fields (contract_id, service_type) don't match project schema. | Update the project's custom-field definitions, then re-push. |
400 Bad Request — time_window | Contracted slot was modelled with seconds outside [0, 172800]. | Verify slot conversion to seconds from midnight; cap at [0, 86400] for single-day windows. |
highway.optimization.error.too_much_requests | Optimizer rate-limited. | Wait 60-90s; if persistent on a large plan, contact support about dedicated capacity. |
Next steps
- Field service with appointments — the closest sibling recipe. If your operation is more order-driven (tickets arrive, technician dispatched) than calendar-driven (visit due every N weeks), start there.
- Webhooks —
stop.reporteddrives the FSM's next-visit scheduling. Make sure the handler is fast and idempotent. - Idempotency — deterministic
external_idis the load-bearing pattern; this recipe leans on it harder than any other. - Capacitated distribution — if your visits also carry physical goods (gas bottles, replacement parts) with hard capacity limits, combine the schedule materialisation here with the constraint matrix there.
