API Specs #
This document captures the Garmin Explore web API behavior used by carnet-de-route and the local command/API contract implemented by the tool.
The Garmin endpoints are undocumented browser APIs. Treat this as an observed contract, not an official Garmin specification.
Authentication #
Garmin Explore authentication is browser-session based.
Observed requirements:
- Login is completed through Garmin SSO in a real Chrome window.
- The authenticated browser context must contain Garmin/Explore session cookies such as
GARMIN-SSOorExploreSession_Prod. - Read endpoints are called as same-origin browser
fetch()requests fromhttps://explore.garmin.com/Map. - Requests include
X-Requested-With: XMLHttpRequest. - The tool must not log cookies, storage state, or raw API bodies by default.
carnet-de-route login opens normal Chrome with a dedicated profile and exports Playwright storage state from the live browser session after the user reaches the Explore map.
Garmin Explore Read Endpoints #
GET /Waypoints/ #
Purpose: fetch waypoint metadata.
Request URL shape:
https://explore.garmin.com/Waypoints/?&_=<unix-ms>
Headers:
Accept: application/json, text/javascript, */*; q=0.01
X-Requested-With: XMLHttpRequest
Response shape:
{
"Waypoints": [
{
"I": 157,
"S": -1,
"D": 1001,
"X": "Camp",
"L": 43.1,
"N": 11.2,
"E": 10.5,
"C": "2026-03-08T18:01:00+00:00",
"M": "2026-03-08T18:01:00+00:00",
"H": false,
"O": false,
"SyncedOnce": false
}
],
"SyncChanged": false
}
Waypoint fields:
D: Garmin waypoint object id. Durable remote identity for waypoint rows.X: waypoint name.L: latitude in decimal degrees.N: longitude in decimal degrees.E: elevation in metres, nullable.C: created timestamp string.M: modified timestamp string.I: Garmin icon id. Seeicons-spec.mdfor the observed icon catalog.S: opaque sync/local sequence id. Do not use as durable identity.H,O,SyncedOnce: opaque booleans. Preserve in raw JSON.
Local handling:
- Decode into
WaypointsResponseandGarminWaypoint. - Preserve the full wrapper raw JSON and each waypoint raw JSON.
- Use
Dto match remote waypoints to stable local IDs. - Store point geometry as GeoPackage
POINT, withx = longitudeandy = latitude.
GET /collections/ #
Purpose: fetch collections, collection metadata, device sync metadata, and membership references.
Request URL shape:
https://explore.garmin.com/collections/?page=<page>&pageSize=<page-size>
Default page size:
100
Response shape:
{
"Collections": [
{
"Name": "Library",
"UUID": "00000000-0000-0000-0000-000000000001",
"CollectionId": 1,
"ShowOnMapShare": false,
"VisibleOnMap": true,
"CreateDate": "2026-05-02T15:08:49.417",
"SyncedDevices": [
{
"DeviceId": 42,
"Included": true,
"PrimaryCollection": "00000000-0000-0000-0000-000000000002"
}
],
"Waypoints": [
{
"UUID": "aaaaaaaa-0000-0000-0000-000000000001",
"ObjectId": 1001,
"Unorganized": false
}
],
"Routes": [],
"Tracks": [],
"Activities": [],
"Courses": [],
"IsLargeCollection": false
}
],
"ShouldSeeV1TrackSyncWarning": false,
"ShouldSeeCollectionsTutorial": false,
"ShouldSeeMapShareCollectionTutorial": false,
"ShouldSeeTrackSyncTutorial": false,
"HasMapShareCollection": false,
"HasSeenMapshareConsent": true,
"DefaultCollectionsCount": 0,
"HitMax": true
}
Collection fields:
CollectionId: Garmin numeric collection id. Durable remote identity.UUID: Garmin collection UUID.Name: collection name.ShowOnMapShare: MapShare visibility flag.VisibleOnMap: Garmin Explore map visibility flag.CreateDate: Garmin timestamp string. Timezone semantics are unclear; preserve as string.SyncedDevices: collection/device sync settings.Waypoints,Routes,Tracks,Activities,Courses: membership reference arrays.IsLargeCollection: opaque Garmin UI/performance flag. Preserve in raw JSON.
Membership reference fields:
ObjectId: Garmin object id for the referenced item.UUID: Garmin object UUID for the referenced item.Unorganized: membership-level Garmin flag. Preserve exactly as reported.
Waypoint join:
Waypoints[].D == Collections[].Waypoints[].ObjectId
Pagination behavior:
- Request page 1 first.
- Continue while
len(Collections) == pageSize. - Stop on an empty page or a page shorter than
pageSize. - Do not use
HitMaxas the sole pagination signal. - Store each raw page response in
remote_pull_responses.
Local handling:
- Decode each page into
CollectionsResponse. - Aggregate pages into
CollectionsPull. - Preserve each page raw JSON and each collection raw JSON.
- Mirror all collection item references in
remote_collection_items. - Mirror
SyncedDevicesinremote_collection_device_sync. - Treat Garmin
Libraryas a local system all-waypoints collection. Do not create editable localcollection_waypointsrows for Library.
See Collection lifecycle endpoints for the create / update / delete protocol used by the Garmin UI for collection metadata itself.
Error Handling #
Common unauthenticated response observed from Garmin Explore:
{
"error": true,
"message": "No user found for request \"/Waypoints/?&_=...\"",
"addrOptions": null,
"state": null
}
Handling requirements:
- Treat non-2xx responses as command errors.
- Redact and truncate response bodies before returning user-facing errors.
- Do not print cookies, storage state, raw HAR content, or complete Garmin response bodies unless a future explicit debug mode is added.
Local CLI Contract #
carnet-de-route login #
Purpose: create an authenticated browser storage state.
Behavior:
- Opens normal Google Chrome with the dedicated profile directory.
- Uses
--remote-debugging-port=9222to export Playwright storage state from the live browser session. - Prompts the user to complete Garmin SSO and wait for the Explore map.
- Writes storage state to
~/Library/Application Support/carnet-de-route/storage-state.json. - Verifies that Garmin session cookies exist before reporting success.
carnet-de-route pull #
Purpose: fetch Garmin Explore waypoints and collections and persist them into GeoPackage.
Important options:
--db PATH
--page-size 100
--headless=true
--browser=chrome
Behavior:
- Opens or creates the GeoPackage DB.
- Runs idempotent migrations.
- Starts a Playwright browser context.
- Imports cookies from saved storage state.
- Fetches
/Waypoints/and/collections/through page-contextfetch(). - Writes pull data inside one SQLite transaction.
- Prints counts only.
Example success output:
pull complete: waypoints=164 collections=20 memberships=279
carnet-de-route status #
Purpose: print high-level local DB status.
Output fields:
- DB path
- schema version
- last successful pull timestamp
- waypoint count
- collection count
- local membership count
- dirty row count
- conflict count
carnet-de-route inspect-db #
Purpose: validate GeoPackage/app schema presence.
Checks include:
gpkg_spatial_ref_sysgpkg_contentsgpkg_geometry_columnswaypointscollectionsremote_objectssync_statepush_snapshotsremote_push_responses- local Trashcan collection
waypoints.geomGeoPackage metadata
GeoPackage Storage Contract #
Core local tables:
waypoints: GeoPackage feature table withfid, stablelocal_id,geom, waypoint fields, dirty flag, and soft-delete flag.collections: local collection rows;system_role = 'library'marks the local all-waypoints Library andsystem_role = 'trashcan'marks the local-only Trashcan.collection_waypoints: editable local non-Library waypoint memberships.remote_objects: Garmin remote identity and raw JSON mirror.remote_collection_items: Garmin collection membership mirror for all item types.remote_collection_device_sync: Garmin device sync mirror.pull_snapshots: pull audit records.remote_pull_responses: raw endpoint response audit records.push_snapshots: push attempt audit records for actual writes only, not dry-runs.remote_push_responses: write response audit records without raw request bodies.sync_state: local/remote hash and conflict state.local_delete_log: tombstones for physical external deletes.
Dirty tracking:
- External updates to user-editable local tables set
dirty = 1. - Physical deletes insert tombstones into
local_delete_log. - Pull never overwrites locally dirty rows when the remote hash changed; it marks conflicts instead.
Garmin Explore Write Endpoints #
Writes use the authenticated browser page context and same-origin relative URLs with X-Requested-With: XMLHttpRequest. Some endpoints use JSON bodies; collection lifecycle endpoints observed in the Garmin UI use query-string parameters with empty bodies. The write APIs are undocumented; this section documents the observed contract implemented by carnet-de-route and adjacent observed protocol details that are intentionally not implemented yet.
POST /Waypoints/Save #
Purpose: create or edit one waypoint.
Create request shape:
{
"id": 0,
"syncId": 0,
"iconIndex": 154,
"name": "Waypoint 1",
"latitude": 43.89814,
"longitude": 11.324501,
"elevation": "100",
"showOnMapShare": false,
"hiddenOnDevice": false
}
Edit request shape:
{
"id": 69653335,
"syncId": -605,
"iconIndex": 154,
"name": "fonte test",
"latitude": 43.89814,
"longitude": 11.324501,
"elevation": "10",
"showOnMapShare": false,
"hiddenOnDevice": false
}
Request fields:
id:0for creates; Garmin waypoint object id for edits.syncId:0for creates; latest remoteWaypoints[].Sfor edits.iconIndex: Garmin icon id.elevation: string elevation in metres, or empty string when no elevation is available.showOnMapShare: false for new waypoints. For edits, preserve the latest remote value so ordinary edits do not change the user's current MapShare state.hiddenOnDevice: currently sent as false.
Response shape:
{
"waypoint": {
"I": 154,
"S": -605,
"D": 69653335,
"X": "fonte test",
"L": 43.89814,
"N": 11.324501,
"E": 10,
"H": false,
"O": false,
"SyncedOnce": true
},
"ID": 69653335,
"SyncId": -605,
"HiddenOnDevice": false
}
Local handling:
- Update waypoint mirrors from the returned compact waypoint.
- Preserve stable local ids.
- Mark successful create/update rows clean after recording the remote mapping and push audit row.
POST /collections/ModifyItemsInManyCollections #
Purpose: add or remove waypoint membership in Garmin collections.
Request shape:
{
"Type": 0,
"ItemIds": [69653335],
"CollectionsToAddTo": ["c9696150-8959-42f2-a100-50289dc6c5c4"],
"CollectionsToRemoveFrom": ["13b2d958-cf61-4c5e-92ff-2f1a78532e3d"]
}
Request fields:
Type:0means waypoint.ItemIds: Garmin waypoint object ids, not local ids or membership UUIDs.CollectionsToAddTo: Garmin collection UUIDs.CollectionsToRemoveFrom: Garmin collection UUIDs.
The endpoint returns no useful body. After successful membership writes, push runs a mandatory collection refresh and reuses the existing remote mirror replacement path.
See Collection lifecycle endpoints for the related create / update / delete protocol used by the Garmin UI for collection metadata itself.
Collection lifecycle endpoints #
The Garmin UI uses a small set of additional collection endpoints for collection-level lifecycle (create, rename, change flags, delete). All of them use query-string parameters with empty bodies, except ModifyCollectionItems which carries a JSON body. None of them send a CSRF header; the session cookie is the only auth.
carnet-de-route push implements the metadata create/update/delete endpoints (Save, Save/<UUID>, Delete/<UUID>). The remaining endpoints (SaveSyncedDevices, ModifyCollectionItems, Get/<UUID>) are documented here but intentionally not implemented; see each section for the reason.
POST /collections/Save #
Purpose: create a new collection.
Request URL shape:
https://explore.garmin.com/collections/Save?name=<URL-encoded name>&showOnMapShare=<true|false>&visibleOnMap=<true|false>
Headers and body:
X-Requested-With: XMLHttpRequest.content-length: 0; noContent-Type; no body.
Response shape: 200 with the newly created GarminCollection JSON object (the same shape as one entry in GET /collections/ Collections[]). The server assigns UUID and CollectionId. CreateDate is full RFC3339 with 7 fractional digits and trailing Z on creation.
Local implementation:
SaveCollectioncalls this endpoint when its requestUUIDis empty.- Store push always sends
showOnMapShare=falsefor new collections, regardless of any local value, because enabling MapShare can publish data.visibleOnMapcomes from the localcollections.visible_on_mapcolumn. - After success,
applySaveCollectionResponseupdates the local row, upsertsremote_objectswith the newgarmin_uuidandgarmin_collection_id, and marks sync state clean forresource_type='collection'.
POST /collections/Save/<UUID> #
Purpose: update an existing collection's metadata.
Request URL shape:
https://explore.garmin.com/collections/Save/<UUID>?name=<URL-encoded name>&showOnMapShare=<true|false>&visibleOnMap=<true|false>
Same headers and empty body as create. The UI re-sends all three query parameters on every update; there is no observed PATCH-style partial update.
Response shape: 200 with the updated GarminCollection JSON object. The response may already reflect membership changes when an immediately preceding ModifyCollectionItems call has run.
Local implementation:
SaveCollectioncalls this endpoint when its requestUUIDis set.- Store push never sends a locally-driven
showOnMapSharevalue for existing collections; it always passes through the last pulled remoteShowOnMapShareparsed fromremote_objects.raw_json. This matches the project's conservative MapShare policy: never silently flip publication state on an edit. - Push planning only emits an update when the writable fields actually differ (
name,visible_on_map). A dirty local row whose only difference isshow_on_mapshareis skipped, because that flag is owned by the remote. - The local row is reconciled from the response the same way create is reconciled.
Unknowns:
- The exact server-side behaviour when only a subset of query parameters is supplied. The UI always sends all three;
carnet-de-routefollows the same pattern.
DELETE /collections/Delete/<UUID> #
Purpose: delete a collection.
Request URL shape:
https://explore.garmin.com/collections/Delete/<UUID>?newPrimaryCollectionUUID=<UUID-or-empty>
Method DELETE, empty body, no Content-Type. The single query parameter newPrimaryCollectionUUID is the UUID to assign as a device's new primary collection when the deleted collection is currently a device primary. The captured trace shows it as empty (?newPrimaryCollectionUUID=) when the collection had no device-primary references; the parameter is always present in the URL, even when empty.
Response shape: 200 with empty body. After deletion, the Garmin UI refreshes GET /collections/.
Local implementation:
DeleteCollectioncalls this endpoint with the collection'sUUID.- Store push blocks deleting any collection whose Garmin UUID appears in
remote_collection_device_sync.primary_collection_uuid. The block runs afterBuildPushPlanbut before the first remote write, so a protected delete fails with a clear error and does not partially apply unrelated changes. Users are instructed to change the primary collection in Garmin Explore and pull again.newPrimaryCollectionUUIDis always sent empty;carnet-de-routenever silently picks a replacement primary collection. - After success,
applyDeleteCollectionremoves the local remote mirror rows (remote_collection_items,remote_collection_device_sync,remote_objects), clears relatedlocal_delete_logentries, marks the local collectiondeleted_locally=1, dirty=0, and writes adelete_collectionaudit row toremote_push_responses.
Unknowns:
- The exact semantics when
newPrimaryCollectionUUIDis filled with a valid replacement collection are not directly observed.
POST /collections/SaveSyncedDevices/<UUID> #
Purpose: change which Garmin devices sync a collection.
Request URL shape:
https://explore.garmin.com/collections/SaveSyncedDevices/<UUID>?deviceIdsToSync=<device-id>[&deviceIdsToSync=<device-id>...]
Empty body, no Content-Type. An empty ?deviceIdsToSync= (or just a trailing ?) means "sync to no devices". The single-device captures show one value; the plural parameter name strongly suggests the .NET MVC convention of repeated query parameters for arrays, but only single-device traffic is directly observed.
Response shape: 200 with a JSON array of the resulting GarminSyncedDevice entries:
[{"DeviceId":2040065,"Included":true,"PrimaryCollection":"fdf83c14-e527-41b1-ac95-85261b67c4b3"}]
Local implementation: not implemented. The local collection schema mirrors SyncedDevices read-only via remote_collection_device_sync. There is no local editable surface for device sync today, and no push code path would call this endpoint, so adding a client method would only widen the API surface without functional value.
Unknowns:
- Behaviour and exact encoding of
deviceIdsToSyncwith multiple devices.
POST /collections/ModifyCollectionItems/<UUID> #
Purpose: add or remove items in one collection across all five item types in a single call.
Headers and body:
Content-Type: application/json; charset=UTF-8.- JSON body with a fixed five-bucket shape per direction:
{
"ItemsToAdd": [
{"Type": 0, "ItemIds": [53424535, 53424534]},
{"Type": 1, "ItemIds": []},
{"Type": 2, "ItemIds": []},
{"Type": 3, "ItemIds": []},
{"Type": 4, "ItemIds": []}
],
"ItemsToRemove": [
{"Type": 0, "ItemGuids": []},
{"Type": 1, "ItemGuids": []},
{"Type": 2, "ItemGuids": []},
{"Type": 3, "ItemGuids": []},
{"Type": 4, "ItemGuids": []}
]
}
Type codes follow the same order as GarminCollection's item fields: 0 waypoints, 1 routes, 2 tracks, 3 activities, 4 courses. The UI always sends all five buckets. Adds use numeric ItemIds; removes use UUID ItemGuids (asymmetric).
Response shape: 200 with empty body.
Local implementation: not implemented. carnet-de-route performs waypoint membership writes through the already-implemented POST /collections/ModifyItemsInManyCollections, which is a better fit for cross-collection "this set of waypoints needs membership X → Y" diffs. The per-collection form would be needed only if push grew to manage routes, tracks, activities, or courses, which are currently mirror-only.
GET /collections/Get/<UUID> #
Purpose: fetch a single collection's full JSON. Returns the same GarminCollection shape as one entry in GET /collections/ Collections[].
Local implementation: not implemented. After remote writes, push runs a mandatory full PullCollections refresh, which already reconciles every collection and its remote mirrors. A single-collection fetch would only be useful as an optimisation, not as a correctness requirement.
DELETE /Waypoints/DeleteMany #
Purpose: delete one or more Garmin waypoints.
Request body:
[69653335]
The body contains Garmin waypoint object ids. The endpoint returns an empty or otherwise non-authoritative body, so push relies on the mandatory post-push waypoint refresh for canonical validation.
Local handling:
- Remove the active Garmin remote mapping after a successful delete.
- Clear the remote-delete tombstone.
- Keep the local waypoint in the local-only Trashcan with clean sync state until the user explicitly purges it.
POST /Home/NotifySyncRequired #
Purpose: Garmin UI-only call observed after collection create/edit/delete and waypoint create/edit/delete operations. It causes the web app to show or update a user-visible banner/reminder that local changes are not yet live on devices until a manual device sync.
Observed body shape:
__RequestVerificationToken=<token>
This is UI-specific. The token is part of Garmin's web framework cross-site-request protection and is not part of the underlying mutation API.
carnet-de-route must not call this endpoint. The remote mutation API persists changes without it: every captured create / edit / delete response in the HAR traces is 200 and the change is visible on a subsequent GET /collections/ or GET /Waypoints/. The CLI has no Garmin web UI banner to update, so calling this endpoint would have no observable effect locally. Remote writes are validated through endpoint responses plus the mandatory post-push refresh.
Push Safety #
push --dry-runprints the detailed plan and does not create audit rows.pushwithout--dry-runprints the detailed plan and requires approval unless--yesis set.- Plan output includes names, local ids, Garmin object ids, and collection names, but not exact coordinates or raw JSON bodies.
- Successful pushes run a mandatory waypoint and collection refresh before reporting success.
- New waypoints are not published to MapShare by default.
- Existing waypoint edits preserve the latest remote MapShare value.
- New collections are created with
showOnMapShare=false; existing collection edits preserve the latest remoteShowOnMapSharevalue. - Library and Trashcan are system collections and are excluded from Garmin membership and lifecycle writes.
- Deleting a collection that is a primary collection for any Garmin device is blocked before any remote write. The user is asked to change the primary collection in Garmin Explore and pull again before deleting.