Skip to main content
Routal

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. Dispatch

Two 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_windows are narrow. A visit contracted for "Monday 9-11am" is pushed with time_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_found fires — 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-up

The 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 — bump next_visit_at by 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). Bump next_visit_at by 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

MetricWhat 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_totalMake-up visits not scheduled within the contract SLA. Goes to compliance dashboard.
recurring.window_breach_rateStops 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_idCauseWhat to do
highway.optimization.error.no_result_foundSchedule 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_stopsTried 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_invalidCustom fields (contract_id, service_type) don't match project schema.Update the project's custom-field definitions, then re-push.
400 Bad Request — time_windowContracted 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_requestsOptimizer 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.
  • Webhooksstop.reported drives the FSM's next-visit scheduling. Make sure the handler is fast and idempotent.
  • Idempotency — deterministic external_id is 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.