Documentation menu
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
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
| 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 keepaccountandapikeyas query parameters. - Geotab: honour the
pathin 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[].addressis a small object ofid,nameandexternalIdsonly. Join toGET /addressesforlatitude/longitude, or geocode theformattedAddress. Only ad-hocsingleUseLocationstops have coordinates inline. - Webfleet coordinates are integer micro-degrees. Divide
latitude_mdegandlongitude_mdegby 1,000,000. The barelatitudeandlongitudefields 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.zonereferences aZonewhose geometry isZone.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, soZone.nameis 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:
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:
"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:
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:
// 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:
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:
{
"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:
{
"locations": [
{ "lat": 53.79648, "lon": -1.54785 },
{ "lat": 53.80106, "lon": -1.54892 },
{ "lat": 53.79648, "lon": -1.54785 }
],
"costing": "auto",
"landmarks": true
}
{
"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.
| 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'sarrivalseconds 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:
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
}'
{
"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— norecent_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— withrecent_trace(up to 100 fixes, oldest first, ending atcurrent_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_mis 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:
progressis geometric, against your plan: how far along the supplied polyline the snapped position sits. It never calls the engine and never re-plans.remainingis 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 fromprogress.plan_remaining_m. A large gap between the two is the signal that something has gone wrong.etais 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_sis 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/progressis 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_hoursis 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 |
| 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.startplus 1–100 stops of{location, label?, service_s?}, anendorround_trip: true, costingautoortruck. Returnsorderedentries carryingorder,stop_index,label,locationandarrival_s, plus totals. Thelabelround-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:startand 1–20stops(each alocationor a free-textnameto geocode, plusdwell_minutes), optionaldepart_atas RFC 3339 for absolute ETAs,optimise: trueto 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 aresolution— whenambiguousis true, readalternativesand re-run with an explicitlocationrather 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 /optimisecontract, limits and errors - Analysis APIs:
POST /route/progressin 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