Skip to content

50,000 free calls a month, card-free. Get an API key →

Documentation menu
docs / fleet-sync · raw .md

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, and the endpoints below — all of which existed before anyone wrote the word "telematics".

The loop

vbnet
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)

Five MapMap calls before the vans leave, then one cheap stateless call per vehicle per polling interval. 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 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

SamsaraWebfleet (WEBFLEET.connect)Geotab (MyGeotab)
Basehttps://api.samsara.com/https://csv.webfleet.com/externhttps://[myserver]/apiv1
ShapeREST + JSONGET/POST with an action= query parameter, outputformat=jsonJSON-RPC 2.0 over POST
AuthAuthorization: Bearer <token> — a dashboard API token, or OAuth 2.0 (recommended for marketplace apps)Authorization: Basic plus ?account=…&apikey=… query parametersAuthenticate 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 needMapMap fieldSamsaraWebfleetGeotab
List vehiclesvehicles[].idGET /fleet/vehiclesdata[].id, .nameaction=showObjectReportExternobjectno (or objectuid), objectnameGet typeName: "Device"id, name
Vehicle position (for your screen, not for us)GET /fleet/vehicles/stats?types=gpslatitude, longitude, timeshowObjectReportExternlatitude_mdeg, longitude_mdeg, pos_timeGet typeName: "DeviceStatusInfo"latitude, longitude, dateTime
Today's stopsjobs[]GET /fleet/routes?startTime=…&endTime=…stops[]action=showOrderReportExtern → one row per orderGet typeName: "Route"routePlanItemCollection
Stop idjobs[].idstops[].idorderidRoutePlanItem.id
Stop coordinatesjobs[].locationstops[].singleUseLocation.{latitude, longitude}ad-hoc stops onlylatitude, longitude (integer micro-degrees)none on the stop — resolve RoutePlanItem.zoneZone.points
Stop address(geocode it)join stops[].address.id to GET /addressesstreet, city, zip, countrynone — only Zone.name / Zone.comment
Time windowjobs[].time_windowsstops[].appointmentWindows[].{startTime, endTime}orderdate + planned_arrival_time, with arrivaltoleranceRoutePlanItem.dateTime (expected arrival)
Service durationjobs[].service_sno field — infer from scheduledArrivalTimescheduledDepartureTimeno fieldRoutePlanItem.expectedStopDuration (milliseconds)
Visit ordersolver outputstops[].sequenceNumberwaypointnumberRoutePlanItem.sequence
Notes for the driver(your layer)stops[].notesordertextRoutePlanItem.comment
Paginationafter cursor + limit (max 512); loop while pagination.hasNextPagenone — filtered whole-report dumpsresultsLimit, or GetFeed with fromVersion
Tightest read limit25 req/s vehicles, 5 req/s routes6 requests per minute on both read actions200 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 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. 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.

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

SamsaraWebfleetGeotab
CreatePOST /fleet/routesaction=sendDestinationOrderExternAdd typeName: "Route"
UpdatePATCH /fleet/routes/{id} (JSON merge patch)updateDestinationOrderExtern, assignOrderExternSet typeName: "Route"
Ordered stopsstops[] with sequenceNumberrepeated wp parametersroutePlanItemCollection with sequence
To the driver's screenroute settings + the Samsara driver apporderautomations drives accept / start / navigateAdd typeName: "TextMessage" with LocationContent
Write rate limit100 req/min300 requests / 30 min200 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.

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.
  • No driver app. Turn-by-turn on the driver's phone is your telematics vendor's app, your own app on the 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

SymptomCauseWhat to do
A stop lands in unassigned with no obvious reasonA soft delivery window modelled as a hard time_windows entry, or a skills value no vehicle carriesRe-read the record's window semantics; check the skill integer exists on at least one vehicle
422 with max_locations in the bodyOver the 200-unique-location capSplit 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 errorCheck the coordinates and the truck constraints for that pair
off_route alerts fire on stationary vehiclesPolling without recent_trace, so method is projectionSend the trace; raise alerts only on map_matched
503 optimisation-not-enabled on a self-hosted gatewayThe solver sidecar is not configuredThe operator sets SN_VROOM_URL; retrying will not help. See self-host
The write-back overwrites a dispatcher's manual changeThe morning plan was pushed without checking for edits made sinceRead-modify-write, or write only to routes still in a draft state
Geocoding cost climbs every weekRe-geocoding a stable customer list dailyCache 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 resolutionwhen 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.

Setup: MCP server, hosted at https://mcp.mapmap.ai/mcp.

Next steps

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