# Fleet sync: routing on top of the telematics you already run

Most fleets that need better routing already have a telematics platform.
Samsara, Webfleet, Geotab, Verizon Connect or a Teltonika-based integrator
already holds the vehicle list, the driver list, the day's stops and — the
part that matters most — every vehicle's position history. Nobody is going
to rip that out to get a better route.

So the question is not "which platform wins". It is **how does a plan
computed here reach a driver over there**, every morning, without either
system becoming the other's dependency.

The usual answer from a routing vendor is a hosted connector: give us your
Samsara token, we will poll your fleet for you. **MapMap does not sell
that, and this page is not building towards one.** The answer here is a
recipe an agent runs *inside your own estate*: it reads your telematics
API with your credentials, calls MapMap for the geometry and the plan, and
writes the result back to your telematics API. MapMap sees stop
coordinates for the length of one request. It never sees a vehicle
position, because it never needs one.

**Status, honestly:** this is a recipe, not a product. There is no
"Connect Samsara" button, no stored OAuth token, no webhook receiver, no
sync status page. What ships is this page, the
[`mapmap-fleet-sync` agent skill](/docs/skills), and the endpoints below —
all of which existed before anyone wrote the word "telematics".

## The loop

```
06:30  read      today's stops + available vehicles  ← your telematics / OMS API
06:31  resolve   stop addresses → coordinates        → GET /geocode (structured)
06:32  sanity    do these stops hold together?       → verify_places (MCP)
06:33  plan      one VRP over the whole depot        → POST /optimise
06:34  draw      per-vehicle navigable geometry      → POST /route (landmarks: true)
06:35  write     ordered stops back to the driver    → your telematics API, or CSV/GPX
──────  the vehicles leave  ──────
every
2 min  check     where is each van against its plan  → POST /route/progress  (stateless)
on
drift  re-plan   lock what is done, re-solve the rest → POST /optimise/replan (stateless)
```

Five MapMap calls before the vans leave, then one cheap stateless call per
vehicle per polling interval, and a re-plan whenever the day moves enough
to be worth it. Nothing between steps is stored by MapMap.

## Step 1: read from the system that already knows

Your telematics platform is the source of truth for two lists, and neither
of them should be retyped:

- **Vehicles available today** — with their depot or current start point,
  their capacity, and whatever tags stand in for capability (tail lift,
  ADR certification, fridge, driver CPC).
- **Today's stops** — address, time window, expected service duration,
  and the customer reference the driver's app will show.

Vendor endpoints and field names are in the [mapping table](#vendor-field-mapping)
below. Two things to settle before you write any of it:

**Positions are for you, not for us.** Read them if your dispatcher's
screen wants them. Do not pass a vehicle's current position into `POST
/optimise` unless the vehicle genuinely starts its day from there — a
mid-shift GPS fix is not a depot, and using one silently turns tomorrow's
plan into a re-plan of today.

**Pull the day once, at a fixed time.** A loop that re-reads the
telematics API every few seconds is the failure mode that gets an
integration rate-limited into uselessness. Read the day's work once, plan
once, and use `POST /route/progress` for the live half — it does not touch
your telematics API at all.

## Vendor field mapping

Checked against each vendor's own public developer documentation on
**3 September 2026**: Samsara's API reference at `developers.samsara.com`,
the WEBFLEET.connect Reference Guide **1.75.0** (revision dated 4 June
2026), and the MyGeotab API reference at `developers.geotab.com`. These are
third-party APIs that change on their own schedule — re-check before you
ship, and treat the table as a starting point rather than a contract.

### Authentication

| | Samsara | Webfleet (WEBFLEET.connect) | Geotab (MyGeotab) |
|---|---|---|---|
| Base | `https://api.samsara.com/` | `https://csv.webfleet.com/extern` | `https://[myserver]/apiv1` |
| Shape | REST + JSON | `GET`/`POST` with an `action=` query parameter, `outputformat=json` | JSON-RPC 2.0 over `POST` |
| Auth | `Authorization: Bearer <token>` — a dashboard API token, or OAuth 2.0 (recommended for marketplace apps) | `Authorization: Basic` **plus** `?account=…&apikey=…` query parameters | `Authenticate` returns a session; then a `credentials` object of `{database, userName, sessionId}` on every call |

Three auth traps worth knowing before you start:

- **Webfleet: credentials in the URL are gone.** The 1.75.0 guide states
  the old `&username=…&password=…` query form was to be removed at the end
  of June 2026. That date has passed. Use HTTP Basic for the user and keep
  `account` and `apikey` as query parameters.
- **Geotab: honour the `path` in the authenticate response.** It is either
  a server URL or the literal string `"ThisServer"`. If it is a URL, every
  subsequent call must go to *that* server. Ignoring it is the classic
  MyGeotab integration bug.
- **Samsara has no CORS.** These calls are server-side only, which is
  correct for this recipe anyway: no telematics credential should ever
  reach a browser.

### Reading vehicles and stops

| What you need | MapMap field | Samsara | Webfleet | Geotab |
|---|---|---|---|---|
| List vehicles | `vehicles[].id` | `GET /fleet/vehicles` → `data[].id`, `.name` | `action=showObjectReportExtern` → `objectno` (or `objectuid`), `objectname` | `Get` `typeName: "Device"` → `id`, `name` |
| Vehicle position (for your screen, not for us) | — | `GET /fleet/vehicles/stats?types=gps` → `latitude`, `longitude`, `time` | `showObjectReportExtern` → `latitude_mdeg`, `longitude_mdeg`, `pos_time` | `Get` `typeName: "DeviceStatusInfo"` → `latitude`, `longitude`, `dateTime` |
| Today's stops | `jobs[]` | `GET /fleet/routes?startTime=…&endTime=…` → `stops[]` | `action=showOrderReportExtern` → one row per order | `Get` `typeName: "Route"` → `routePlanItemCollection` |
| Stop id | `jobs[].id` | `stops[].id` | `orderid` | `RoutePlanItem.id` |
| Stop coordinates | `jobs[].location` | `stops[].singleUseLocation.{latitude, longitude}` — **ad-hoc stops only** | `latitude`, `longitude` (integer **micro-degrees**) | **none on the stop** — resolve `RoutePlanItem.zone` → `Zone.points` |
| Stop address | (geocode it) | join `stops[].address.id` to `GET /addresses` | `street`, `city`, `zip`, `country` | **none** — only `Zone.name` / `Zone.comment` |
| Time window | `jobs[].time_windows` | `stops[].appointmentWindows[].{startTime, endTime}` | `orderdate` + `planned_arrival_time`, with `arrivaltolerance` | `RoutePlanItem.dateTime` (expected arrival) |
| Service duration | `jobs[].service_s` | **no field** — infer from `scheduledArrivalTime` → `scheduledDepartureTime` | **no field** | `RoutePlanItem.expectedStopDuration` (**milliseconds**) |
| Visit order | solver output | `stops[].sequenceNumber` | `waypointnumber` | `RoutePlanItem.sequence` |
| Notes for the driver | (your layer) | `stops[].notes` | `ordertext` | `RoutePlanItem.comment` |
| Pagination | — | `after` cursor + `limit` (max 512); loop while `pagination.hasNextPage` | **none** — filtered whole-report dumps | `resultsLimit`, or `GetFeed` with `fromVersion` |
| Tightest read limit | — | 25 req/s vehicles, **5 req/s routes** | **6 requests per minute** on both read actions | 200 req/min on `Route` |

Three of those cells decide how much work this is:

- **Samsara stops usually carry no coordinates.** For an address-book stop,
  `stops[].address` is a small object of `id`, `name` and `externalIds`
  only. Join to `GET /addresses` for `latitude`/`longitude`, or geocode the
  `formattedAddress`. Only ad-hoc `singleUseLocation` stops have
  coordinates inline.
- **Webfleet coordinates are integer micro-degrees.** Divide
  `latitude_mdeg` and `longitude_mdeg` by 1,000,000. The bare `latitude`
  and `longitude` fields on the object report are degrees-minutes-seconds
  *strings*, not numbers, and parsing them as floats is a bug that puts
  every vehicle off the coast of Africa.
- **A Geotab stop is a geofence, not a point.** `RoutePlanItem.zone`
  references a `Zone` whose geometry is `Zone.points`, a polygon. Derive a
  centroid for the optimisation, and keep the polygon if you want arrival
  detection. There is no address string anywhere on the stop, so `Zone.name`
  is usually what you end up geocoding.

Webfleet's **6 requests per minute** on `showObjectReportExtern` and
`showOrderReportExtern` is the single hardest constraint in this table, and
it is exactly why the loop above reads the day once. Webfleet's own guide
points at the message queue (`popQueueMessagesExtern`) rather than polling
the object report if you need continuous tracking data.

Note also that **only Geotab carries an explicit service duration**. On
Samsara it is the gap between scheduled arrival and departure; on Webfleet
there is no such field at all, so `service_s` has to come from your own
per-job-type table. Do not default it to zero and let the solver believe
stops are instant — every ETA after the first will be optimistic, and the
error compounds down the round.

## Step 2: resolve stops to coordinates you can defend

Telematics systems store addresses as people typed them. A VRP over
guessed coordinates produces a confident, wrong plan, and the driver is
the one who discovers it.

Use the **structured** form of `GET /geocode` rather than pushing the
whole address into `q`. Components exclude rather than merely reorder, so
`city=Leeds` means the answer really is in Leeds:

```sh
curl -fsS "$BASE/geocode?housenumber=1&street=Wellington+Place&city=Leeds&postcode=LS1+4AP&country=GB" \
  -H "Authorization: Bearer $SN_API_KEY"
```

Every hit then carries a `match` object saying how far to trust it:

```json
"match": {
  "components": {
    "housenumber": "matched",
    "street": "matched",
    "city": "matched",
    "postcode": "matched"
  },
  "score_gap": 3.482,
  "source": "mapmap-index"
}
```

The rule that makes this worth doing: **route the exceptions to a human,
not to the solver.** A stop whose `postcode` comes back `unmatched`, or
whose `score_gap` is near zero, has not been geocoded — it has been
guessed at. Hold it out of the optimisation and put it on a dispatcher's
exception list. A plan with 58 stops and 2 flagged beats a plan with 60
stops and one van in the wrong town.

For a whole depot at once, `POST /geocode/batch` takes up to **1,000**
queries in one call and answers in order. A query that fails takes only
itself down — its result carries a `problem` object instead of `features`,
and the rest of the batch is unaffected:

```sh
curl -fsS -X POST "$BASE/geocode/batch" \
  -H "Authorization: Bearer $SN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"queries": [
        {"street": "Wellington Place", "housenumber": "1", "city": "Leeds", "postcode": "LS1 4AP"},
        {"street": "Deansgate", "housenumber": "300", "city": "Manchester", "postcode": "M3 4LQ"},
        {"lat": 53.79648, "lon": -1.54785}
      ]}'
```

Billing is one Standard call per query: a batch is a delivery mechanism,
not a discount, so ten geocodes cost ten geocodes whether they arrive in
ten calls or one.

Cache the result against the customer record in *your* system. A delivery
address does not move between Tuesdays, and re-geocoding a stable round
every morning is the single easiest way to spend money on nothing.

### Sanity-check the day before you plan it

The `verify_places` [MCP tool](/docs/mcp) answers a different question from
the geocoder: not "where is this" but "does this day hold together". Pass
the stops as structured claims with `sequence` and `claimed_time` values
and it checks the legs for feasibility through `POST /matrix`, catching the
booking that has one van in Bath at 09:00 and Edinburgh at 10:00.

Read its contract carefully, because it is deliberately narrow. Every
claim resolves to one of exactly three verdicts — `verified`,
`contradicted` or `unverified` — never a boolean. **It never asserts that a
named business does not exist or has closed**: a missing match is always
`unverified`, and treating `unverified` as "this address is fake" is a
misreading that will have your dispatcher ringing real customers to ask
whether they still exist. `contradicted` currently means one thing only: a
leg the routing engine proves cannot be driven in the stated time, with
the computed travel time attached as evidence.

## Step 3: build the optimisation request

This is where the honesty lives, because it is where a telematics record
gets translated into a solver constraint and the translation is lossy.

`POST /optimise` takes `vehicles` and `jobs`; the full contract is in
[route optimisation](/docs/optimisation). What matters for a sync is the
mapping.

### Times are seconds on an epoch you choose

There is no timezone in the request. Times are plain seconds on any
consistent scale, and seconds-since-midnight-local is the one that makes a
depot's day readable: `28800` = 08:00, `61200` = 17:00. Response `arrival`
values come back on the same scale, so converting them for the write-back
is one addition. Pick the scale once, at the top of your adapter, and
never mix.

### Skills are integers, and the mapping is yours to keep

Telematics platforms carry capability as free text: a tag, an asset type,
a custom field reading `"tail-lift"`. `POST /optimise` takes `skills` as
**integer arrays**, and a job requiring a skill its vehicle lacks is never
assigned to it. So you need a lookup table, and it needs to live in
version control rather than being derived from whatever strings the API
returned this morning:

```js
// One place. Never derived at runtime from vendor tag text — a renamed
// tag would silently drop a constraint rather than fail loudly.
const SKILL = { TAIL_LIFT: 1, ADR: 2, FRIDGE: 3, CPC_DRIVER: 4 };
```

A tag you have not mapped is not a skill of `0`. It is an **error**: stop
and ask, or you have quietly planned an ADR load onto a van with no ADR
driver.

### Time windows: map what the record means, not what it says

A telematics or order record usually carries a promised delivery window.
Map it to a job's `time_windows` array. But a "requested" or "preferred"
window is not a constraint — modelled as one, it makes the problem
infeasible and the stop lands in `unassigned` with no explanation the
dispatcher can act on. If your OMS distinguishes hard from soft windows,
only the hard ones become `time_windows`; carry the soft ones as sort
preference in your own layer.

### Driver hours

Give a vehicle a `breaks` array, or set top-level `eu_drivers_hours: true`
to auto-generate a Regulation (EC) No 561/2006 break — 45 minutes after at
most 4.5 hours of driving — for every vehicle that has a `time_window` but
no explicit breaks. It is a **single-shift approximation**: split breaks,
daily rest and weekly rest are not modelled, so post-validate against your
tachograph system rather than treating the plan as a compliance record.
`max_travel_time` is the vehicle's daily *driving* limit in seconds; it
excludes service, setup and waiting time.

### Worked request

Two vans out of a Leeds depot, one of them needing a tail lift, an 08:00 to
17:00 shift:

```sh
curl -fsS -X POST "$BASE/optimise" \
  -H "Authorization: Bearer $SN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "vehicles": [
    { "id": 4021,
      "start": { "lat": 53.79648, "lon": -1.54785 },
      "end":   { "lat": 53.79648, "lon": -1.54785 },
      "capacity": [1200],
      "skills": [1],
      "time_window": [28800, 61200],
      "max_travel_time": 32400 },
    { "id": 4022,
      "start": { "lat": 53.79648, "lon": -1.54785 },
      "end":   { "lat": 53.79648, "lon": -1.54785 },
      "capacity": [900],
      "time_window": [28800, 61200] }
  ],
  "jobs": [
    { "id": 90114, "location": { "lat": 53.80106, "lon": -1.54892 },
      "service_s": 600, "delivery": [180], "skills": [1],
      "time_windows": [[32400, 43200]] },
    { "id": 90115, "location": { "lat": 53.74215, "lon": -1.62301 },
      "service_s": 300, "delivery": [220] }
  ],
  "eu_drivers_hours": true
}'
```

The response comes back with every vehicle's `steps` in visit order:

```json
{
  "code": 0,
  "profile": "auto",
  "summary": { "cost": 9840, "routes": 2, "unassigned": 0,
               "duration": 9840, "service": 900, "waiting_time": 0,
               "distance": 84300 },
  "unassigned": [],
  "routes": [
    { "vehicle": 4021, "cost": 5120, "duration": 5120, "distance": 41200,
      "service": 600, "waiting_time": 0,
      "steps": [
        { "type": "start", "arrival": 28800, "duration": 0,
          "service": 0, "waiting_time": 0, "load": [180],
          "location": { "lat": 53.79648, "lon": -1.54785 } },
        { "type": "job", "id": 90114, "arrival": 32400, "duration": 620,
          "service": 600, "waiting_time": 2980, "load": [0],
          "location": { "lat": 53.80106, "lon": -1.54892 } },
        { "type": "end", "arrival": 33920, "duration": 5120,
          "service": 0, "waiting_time": 0, "load": [0],
          "location": { "lat": 53.79648, "lon": -1.54785 } }
      ] }
  ]
}
```

**Read `unassigned` before you read anything else.** Unassignable tasks are
never silently dropped — each appears there as `{id, type, location}` — and
a write-back that ignores the array will quietly deliver a short day to a
driver and a missed SLA to a customer. Every id in `unassigned` belongs on
the dispatcher's exception list next to the geocoding failures.

Two limits to design around: **200 unique locations** per request (a `422`
carrying `max_locations` and `locations`, so split by depot or by round),
and a **1,500 km** matrix span for car and truck costing. A national UK
depot fits; a plan spanning the Highlands to southern Spain does not.

For a truck fleet, add `"costing": "truck"` and an `adr` profile and the
plan will never assume a leg a lorry cannot legally drive — the gateway
computes the matrix through its own engine with those constraints applied,
rather than letting the solver do its own routing. `adr` without
`"costing": "truck"` is a `400`. See
[truck and ADR routing](/docs/adr).

## Step 4: turn the order into something a driver can follow

`POST /optimise` returns visit order, not geometry — `options.g` is not
supported and `{"g": true}` is a `400`. Fetch each vehicle's line with one
`POST /route` over its ordered stops.

Add `"landmarks": true` and eligible manoeuvres gain a
`landmark_instruction` anchored to a recognisable place, which is what a
passenger would actually have said:

```json
{
  "locations": [
    { "lat": 53.79648, "lon": -1.54785 },
    { "lat": 53.80106, "lon": -1.54892 },
    { "lat": 53.79648, "lon": -1.54785 }
  ],
  "costing": "auto",
  "landmarks": true
}
```

```json
{
  "trip": {
    "legs": [{
      "maneuvers": [{
        "type": 10,
        "instruction": "Turn right onto Wellington Street",
        "landmark_instruction": "Turn right just after the Shell garage"
      }]
    }]
  },
  "landmarks": { "annotated": 3 }
}
```

It is additive: the engine's own `instruction` is never replaced, so a
route where nothing is recognisable degrades to the ordinary text rather
than breaking.

Keep the polyline. `POST /route` returns geometry at **precision 6**, and
that same string is what `POST /route/progress` wants as
`geometry_polyline6` in step 6 — which is the cheap path, because the plan
is then never recomputed.

### An EV fleet

If the round is electric, `POST /v1/ev/plan` works out the charge stops for
a whole journey — consumption over the route's real legs, stops chosen so
the state of charge never breaches your reserve floor, and charge times
integrated over the vehicle's own charging curve rather than energy divided
by peak power. It answers `feasible: false` with a named cause and the
furthest reachable point rather than inventing a plan.
`POST /v1/charging/along` ranks charge points along an existing route.
Both are detailed in [analysis](/docs/analysis), both are `501` on a
deployment with no charge-point directory configured, and both return a
`charging_attribution` block naming the operators covered — display it,
because an empty result means "none from these operators", never "no
chargers here".

## Step 5: write the plan back

A plan the driver never sees is not a plan. All three major platforms have
a write path, and they are shaped differently enough that the adapter is
the real work.

| | Samsara | Webfleet | Geotab |
|---|---|---|---|
| Create | `POST /fleet/routes` | `action=sendDestinationOrderExtern` | `Add` `typeName: "Route"` |
| Update | `PATCH /fleet/routes/{id}` (JSON merge patch) | `updateDestinationOrderExtern`, `assignOrderExtern` | `Set` `typeName: "Route"` |
| Ordered stops | `stops[]` with `sequenceNumber` | repeated `wp` parameters | `routePlanItemCollection` with `sequence` |
| To the driver's screen | route settings + the Samsara driver app | `orderautomations` drives accept / start / navigate | `Add` `typeName: "TextMessage"` with `LocationContent` |
| Write rate limit | 100 req/min | 300 requests / 30 min | 200 req/min |

**Samsara.** `POST /fleet/routes` takes a `name`, at least two `stops`, and
one of `driverId` or `vehicleId` (one, never both). Each stop takes either
an `addressId` or a `singleUseLocation` of
`{latitude, longitude, address, radiusMeters}`, plus
`scheduledArrivalTime`, `scheduledDepartureTime`, `name`, `notes`,
`appointmentWindows` and `sequenceNumber`. **The trap is `PATCH`:** it is a
JSON merge patch, so arrays *replace* rather than append. Send the complete
new `stops` array and include each surviving stop's `id`, or the ones you
left out are deleted. Samsara's own documentation also warns that modifying
stops whose scheduled arrival has already passed produces unpredictable
behaviour, which is a good argument for writing the whole day before the
vans leave and treating mid-shift edits as a separate, narrower operation.

**Webfleet.** `sendDestinationOrderExtern` sends an order with its target
coordinates straight to the in-vehicle navigation. The ordered sequence is
the repeated **`wp`** parameter, one per waypoint, formatted
`<latitude>,<longitude>,[description],[notify],[visible]` — coordinates in
integer micro-degrees again. Capacity depends on the device generation
(the 1.75.0 guide lists up to 1,000 waypoints per order on some TomTom PRO
models and 250 on others), so check the fleet's hardware before promising a
60-stop round in one order. Use `POST` rather than `GET` once you have more
than a handful of `wp` values. Two things to design for: `orderautomations`
controls what the driver's screen does on receipt (accept, start, navigate,
skip the route summary), and **every order action is asynchronous** — a
successful response means Webfleet accepted the order, not that it reached
the device. Track delivery through the order state, not the HTTP status.

**Geotab.** Two mechanisms, and you may want both. `Add` with
`typeName: "Route"` writes the plan as a `routePlanItemCollection` of
`RoutePlanItem` entries carrying `sequence` and a `zone` — which means the
stops must exist as `Zone` entities first, so a round of new addresses is
an `Add` of Zones before it is an `Add` of a Route. To put waypoints in
front of the driver, `Add` a series of `TextMessage` entities whose
`messageContent` is `LocationContent` (`latitude`, `longitude`, `address`,
`message`), sharing a `routeId`. **`isDirectionToVehicle` must be `true`**:
`false` means the message came *from* the vehicle, and getting it the wrong
way round is a silent no-op. Note `LocationContent.id` is deprecated in
favour of `routeId`.

### Where there is no write API

Not every platform will take a plan back, and some fleets deliberately do
not want an integration writing into their system of record. Export
instead:

- **CSV** for a dispatcher to import or a driver to read: one row per stop
  with visit order, arrival offset, address, customer reference and the
  vehicle id. Convert `/optimise`'s `arrival` seconds back to local clock
  time using the epoch you chose in step 3.
- **GPX** for a satnav in the cab: a `<rte>` of `<rtept>` elements in visit
  order, or `<wpt>` elements if the unit wants waypoints. Almost every
  aftermarket and OEM navigation unit reads it, which makes it the
  lowest-common-denominator write path when nothing else exists.

Both are worth building even where a write API exists: they are what you
fall back on the morning the telematics API is down and the vans still have
to leave.

## Step 6: the execution loop, statelessly

Once the vans are out, the dispatcher wants one thing: **is this vehicle
going to be late, and where is it now against its plan?**

`POST /route/progress` answers that in one call, and answers it without
storing anything. The plan and the position arrive on every request and are
gone when it is answered:

```sh
curl -fsS -X POST "$BASE/route/progress" \
  -H "Authorization: Bearer $SN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "geometry_polyline6": "}~ycbBhavgN...",
  "current_position": { "lat": 53.78210, "lon": -1.58940 },
  "recent_trace": [
    { "lat": 53.79010, "lon": -1.56220 },
    { "lat": 53.78644, "lon": -1.57510 }
  ],
  "off_route_threshold_m": 60
}'
```

```json
{
  "costing": "auto",
  "stateless": true,
  "privacy": "no positions are stored: the plan and the position are read from this request, answered, and discarded",
  "plan": { "length_m": 41217.4, "source": "geometry_polyline6" },
  "position": {
    "input":   { "lat": 53.7821, "lon": -1.5894 },
    "snapped": { "lat": 53.7821, "lon": -1.5894 },
    "method": "map_matched",
    "along_m": 12844.9
  },
  "progress": {
    "fraction": 0.3116,
    "travelled_m": 12844.9,
    "plan_remaining_m": 28372.5
  },
  "off_route": false,
  "off_route_m": 11.2,
  "off_route_threshold_m": 60.0,
  "arrived": false,
  "remaining": { "distance_m": 28610.0, "duration_s": 2244.0, "method": "engine" },
  "eta": {
    "duration_s": 2244.0,
    "arrival_estimate_utc": "2026-09-03T11:42:19Z",
    "traffic": {
      "available": true,
      "method": "link_coverage_v1",
      "covered_pct": 0.62,
      "confidence": 0.78,
      "band": "live",
      "legs": [ { "covered_pct": 0.62, "band": "live" } ]
    }
  }
}
```

### Send `recent_trace`, or do not trust `off_route`

The `method` field always says which of two answers you got, and they are
not interchangeable.

- **`projection`** — no `recent_trace`. The single fix is projected onto
  the plan polyline geometrically. It is exact about what it measures, but
  a lone fix has no heading and no history, so 30 m of urban-canyon error
  beside a dual carriageway is indistinguishable from being on the plan.
  **A parked van with a poor fix will read as off route.** Do not raise an
  alert from this variant.
- **`map_matched`** — with `recent_trace` (up to **100** fixes, oldest
  first, ending at `current_position`). The trace is map-matched through
  the engine and the last matched point is what gets projected. "Which
  road is this vehicle actually on" is a road-network question and the
  engine answers it against the real graph. `off_route_m` is then a
  road-to-plan distance rather than a GPS-noise-to-plan distance, and it is
  the variant an alert should read.

Your telematics platform already holds a rolling trace per vehicle. Send
the last few fixes. It costs nothing extra and it is the difference
between a working exception queue and one the dispatcher learns to ignore.

### Three numbers that are deliberately not merged

`progress`, `remaining` and `eta` answer different questions, and the
response keeps them apart so you can see when they disagree:

- `progress` is geometric, against **your plan**: how far along the
  supplied polyline the snapped position sits. It never calls the engine
  and never re-plans.
- `remaining` is the engine's answer **from where the vehicle actually
  is** — a fresh search, not a walk of the plan's tail. On an off-route
  vehicle it already prices getting back, so it can legitimately differ
  from `progress.plan_remaining_m`. A large gap between the two *is* the
  signal that something has gone wrong.
- `eta` is that remaining duration as a wall-clock arrival against the
  gateway's own UTC clock, with the per-leg traffic provenance always
  attached: what fraction of the remaining leg has traffic data, from what
  source, and how stale. A client whose clock differs from the gateway's
  sees an offset arrival time; `remaining.duration_s` is the clock-free
  figure to prefer when that matters.

Within **25 m** of the plan's final point no remaining leg is computed at
all — `arrived: true` and `remaining.method: "arrival_radius"`. That is
deliberate: the last few metres of a journey are exactly when a navigating
client polls hardest, and a zero-length route is not a route.

### When the day has drifted too far to watch

Checking progress tells you the plan is wrong; it does not fix it. When a
customer cancels, an urgent job lands or a van breaks down, re-plan the
rest of the day with
[`POST /optimise/replan`](/docs/api-reference#re-planning-mid-shift). It
takes the same morning problem you already built in Step 3, plus a
`progress` array of what each vehicle has completed and a `changes` block
of what has moved. Completed stops are locked — they keep their vehicle and
their order and are never re-solved — and you are billed only on the
remaining problem.

It is stateless in exactly the way the rest of this loop is: nothing about
your plan or your vehicles is stored between calls, so you send the day
back on each one. There is no `request_id` to keep.

### Poll rate and cost

`POST /route/progress` bills a flat **5 calls** of its price class, and
Premium only if the body carries truck costing or an `adr` profile. One
request triggers at most three internal engine calls and no matrix fan-out
at all.

Do the arithmetic before you pick a cadence. A 60-vehicle fleet polled
every two minutes over a nine-hour shift is 270 polls per vehicle, 16,200
requests, and at five calls each **81,000 calls a day**. Halving the
cadence halves the bill. Poll on **your** cadence, not the tracker's: your
telematics platform may report every 10 seconds, but a dispatcher screen
does not need a fresh ETA more than once or twice a minute, and the ETA
between two polls is a straight-line interpolation you can do locally for
free.

## What MapMap does not do

Say this out loud before you scope the work, because it decides what you
are actually building.

- **No hosted connectors.** There is no "Connect Samsara" or "Connect
  Geotab" button, no stored third-party OAuth token, no sync daemon, no
  webhook receiver, no integration marketplace. The agent runs in your
  estate with your credentials. If you want a managed connector, that is a
  telematics-vendor or iPaaS purchase, not this one.
- **No vehicle tracking, and no plans for one.** MapMap does not store
  vehicle positions, does not maintain a fleet or asset register, does not
  keep a trip history, and has no geofencing product. `POST
  /route/progress` is stateless by construction: the position is read from
  the request, answered, and discarded — never written to a database, a
  disk or a log. That claim is guarded by tests in the gateway, not only
  asserted here. See [trust](/trust).
- **No driver app.** Turn-by-turn on the driver's phone is your telematics
  vendor's app, your own app on the [SDKs](/docs/sdks), or a CSV/GPX handed
  to whatever navigation the cab already has.
- **No hours-of-service system of record.** `eu_drivers_hours` is a
  single-shift approximation inside the optimisation, not a tachograph
  record and not a compliance sign-off.
- **No order management.** Jobs, customers, SLAs, proof of delivery and
  invoicing stay where they are.

What that buys you is a boundary you can put in a DPIA and defend: the
personal data in fleet telematics is location data about identifiable
drivers, and it stays in the system that was already processing it.

## Failure modes worth handling before they happen

| Symptom | Cause | What to do |
|---|---|---|
| A stop lands in `unassigned` with no obvious reason | A soft delivery window modelled as a hard `time_windows` entry, or a `skills` value no vehicle carries | Re-read the record's window semantics; check the skill integer exists on at least one vehicle |
| `422` with `max_locations` in the body | Over the 200-unique-location cap | Split by depot or round before submitting, not after the failure |
| A duration in the response is absurdly large (millions of seconds) | A location pair is unreachable under the chosen costing — the matrix cell gets a sentinel cost, not an error | Check the coordinates and the truck constraints for that pair |
| `off_route` alerts fire on stationary vehicles | Polling without `recent_trace`, so `method` is `projection` | Send the trace; raise alerts only on `map_matched` |
| `503 optimisation-not-enabled` on a self-hosted gateway | The solver sidecar is not configured | The operator sets `SN_VROOM_URL`; retrying will not help. See [self-host](/docs/self-host) |
| The write-back overwrites a dispatcher's manual change | The morning plan was pushed without checking for edits made since | Read-modify-write, or write only to routes still in a draft state |
| Geocoding cost climbs every week | Re-geocoding a stable customer list daily | Cache coordinates against the customer record; re-resolve only on address change |

## For agents

The whole loop is available as MCP tools, so an agent can run it without
writing an HTTP client: `geocode` and `verify_places` for step 2,
`optimise_routes` for step 3, `route` for step 4, `matrix` where you want
your own assignment logic, and `plan_ev_route` for an electric round. For a
single vehicle's day the two composition tools are usually enough:

- **`order_stops`** — one run's stops in the best visiting order. `start`
  plus 1–100 stops of `{location, label?, service_s?}`, an `end` or
  `round_trip: true`, costing `auto` or `truck`. Returns `ordered` entries
  carrying `order`, `stop_index`, `label`, `location` and `arrival_s`, plus
  totals. The `label` round-trips, so it is the natural place to carry your
  telematics stop id through the solver.
- **`plan_day`** — a whole itinerary as one navigable multi-stop route:
  `start` and 1–20 `stops` (each a `location` or a free-text `name` to
  geocode, plus `dwell_minutes`), optional `depart_at` as RFC 3339 for
  absolute ETAs, `optimise: true` to reorder, `return_to_start`. Returns
  the stops in visit order with per-leg duration and distance, arrival and
  departure times, totals, and the polyline. Geocoded names carry a
  `resolution` — **when `ambiguous` is true, read `alternatives` and re-run
  with an explicit `location` rather than trusting the guess.**

There is also an agent skill for this exact recipe:
`mapmap-fleet-sync`, installable with
`npx skills add Mapmapai/mapmap-agent-skills`. See
[agent skills](/docs/skills).

Setup: [MCP server](/docs/mcp), hosted at `https://mcp.mapmap.ai/mcp`.

## Next steps

- [Route optimisation](/docs/optimisation): the full `POST /optimise` contract, limits and errors
- [Analysis APIs](/docs/analysis): `POST /route/progress` in full, plus matrix, EV planning and charging
- [Conventions](/docs/conventions): base URL, auth, units, the RFC 9457 error envelope
- [Truck and ADR routing](/docs/adr): dimensional limits and dangerous-goods tunnel codes in the plan
- [MCP server](/docs/mcp): the tool surface an agent drives this loop with
- [Trust](/trust): what leaves your estate, and what does not
