Skip to content

Device API: registration and pairing

Garmin Wear OS Apple Watch Server
Status ✅ ✅ ⏳ ✅
Registration source/DeviceRegistration.mc wear/…/sync/DeviceApi.kt (registerIfNeeded) — app/controllers/api/v1/devices_controller.rb#register
Pairing source/PairingView.mc wear/…/sync/DeviceApi.kt, presentation/SailEverApp.kt (pairing screen) — api/v1/devices_controller.rb, app/controllers/devices_controller.rb (/pair)
Token check source/TripStore.mc wear/…/data/TripStore.kt, data/TokenCipher.kt — app/controllers/api/v1/base_controller.rb

Decision record: ADR 003. Verified against code: 2026-10-11

Purpose

Every installed watch app tells the server that it exists (registration), so installs and versions can be counted without an account. A user then links the watch to their account with a 6-digit code (pairing). Pairing issues a device token, and only a paired watch can upload tracks or get tides.

Registration and pairing are separate:

Registration Pairing
What Anonymous install record: platform, model, app version Link to a user account
Needs an account No Yes
Endpoint POST /api/v1/devices/register POST /api/v1/devices/pairing_requests, poll, web /pair
Result devices row + one device_registrations row per app version devices.user_id, paired_at, device token

Both act on the same devices row, keyed by (platform, device_uid). In the signed-in UI a paired watch is a device (glossary).

Platforms

Device::PLATFORMS = garmin, apple_watch, wear_os. Any other value is rejected with 422 on registration.

Platform platform device_uid device_model app_version
Garmin garmin System.DeviceSettings.uniqueIdentifier (firmware-backed, survives reinstall); "uid_" + 20 random hex digits in Storage when missing (simulator) partNumber (e.g. 006-B4631-00), "Garmin" when missing AppVersion string resource
Wear OS wear_os "uid_" + 20 random hex digits, kept in SharedPreferences (excluded from backup, so a reinstall gives a new uid) Build.MODEL BuildConfig.VERSION_NAME
Apple Watch apple_watch ⏳ — — —

For garmin, the server turns a part number into a model name with config/garmin_devices.yml (Sailever::GarminDevices). Other platforms use device_model as the name. A name the user typed on the Devices page (display_name_manual) is never overwritten.

Authentication

Authenticated endpoints read Authorization: Bearer <device_token>. The server takes the last space-separated word of the header, hashes it with SHA-256 and looks up an active device (revoked_at null) with that token_hash. The device must also be paired (user_id and paired_at set).

Case Response
No token 401 {"error":"Missing authorization token"}
Unknown, revoked or unpaired 401 {"error":"Invalid or revoked device token"}
Valid Request runs as that device and its user; last_seen_at is updated

Tokens look like dt_ + 48 hex characters. Only the SHA-256 hash is stored. A token does not expire and is not rotated on upload. It stops working when:

  • the device is paired again (new owner or same owner), which clears token_hash,
  • the user removes the device on the web (sets revoked_at),
  • the watch calls POST /devices/unpair.

On 401 both watch apps delete their token and keep queued files (track-sync.md).

Garmin

The token is kept in Application.Storage.

Wear OS

The token is encrypted with AES-GCM using an Android Keystore key. Backup and device transfer are excluded.

Registration

POST /api/v1/devices/register, no authentication.

Field Default Note
device_uid — Required. Blank → 422 {"error":"device_uid is required"}
platform garmin Lower-cased. Unknown → 422 {"error":"Unsupported platform: <value>"}
device_model garmin_watch
app_version 1.0 String

The server finds or creates the device by (platform, device_uid), sets device_model, app_version, last_registered_app_version and last_seen_at, and refreshes display_name unless it was typed by the user. It finds or creates one device_registrations row for this app version. Pairing is never touched.

Response: 201 for a new device, else 200:

{ "status": "ok", "platform": "garmin", "device_uid": "4a2e8c9b…", "app_version": "1.0.1",
  "is_new_device": false, "is_new_version": true, "registered": true, "paired": false }

Client rule (both apps): register only after the privacy consent given on the watch (../watchapp/sync-and-pairing.md): at app start when this version has not been registered yet, and right after the sailor answers Yes. Without consent the watch sends no request at all. The consent itself is never sent; there is no field for it. On 200/201 remember the version. Failures are silent; the next app start tries again.

Pairing

Watch                                    Server                               Web (/pair, signed in)
POST /devices/pairing_requests  ───────► new code + poll token, 10 min
◄─── 201 {pairing_code, poll_token, expires_in_seconds: 600, poll_interval_seconds: 5}
shows the code
GET  /devices/pairing_requests/poll ───► 202 pending  (every 5 s)
                                                                    ◄──── user enters the code
                                         device → this user, old token cleared, code consumed
GET  /devices/pairing_requests/poll ───► 200 {status: paired, device_token}
stores the token

1. Pairing request

POST /api/v1/devices/pairing_requests, no authentication. Fields as for registration (platform, device_uid, device_model, app_version). A missing device_uid creates a new device with a random uid_….

The server:

  • creates a 6-digit code (%06d, random). It tries up to 10 times to find one that no unexpired request uses.
  • creates a poll token pt_ + 64 hex characters (256 bit). Client-supplied codes or poll tokens are ignored.
  • finds or creates the device, keeps its model and name if set, and stores the SHA-256 hashes of code and poll token with pairing_expires_at = now + 10 min.
  • does not change an existing pairing. The current owner keeps access until the code is entered.

Response 201:

{ "pairing_code": "482915", "poll_token": "pt_…", "expires_in_seconds": 600, "poll_interval_seconds": 5 }

2. Entering the code (web)

POST /pair with pairing_code, signed in (page and copy: ../server/devices.md). The server finds a device whose unexpired code hash matches. Then, in one update:

Column New value
user_id the signed-in user (also when the watch belonged to someone else)
paired_at now
token_hash null (the previous owner's token stops working)
pairing_code_hash null (the code works once)
revoked_at null

The cached token is deleted. Tracks uploaded before stay with the previous owner. Unknown or expired code → 422 with a message on the page.

3. Polling

GET /api/v1/devices/pairing_requests/poll?poll_token=pt_…, no authentication.

Case Response
No poll_token 400 {"error":"Missing poll_token"}
Unknown token, no expiry, or expired 410 {"status":"expired"}
Device not paired yet 202 {"status":"pending"}
Device paired 200 {"status":"paired","device_token":"dt_…"}

On the paired answer the server creates a new token (unless one was created in the last 10 min and is still in the cache), stores its hash and token_rotated_at, and clears poll_token_hash. The token is therefore delivered once; the next poll with the same poll token gets 410.

Client rule (both apps): only after the privacy consent (without it the consent view opens in place of the pairing screen), request a code when the pairing screen opens, poll every 5 s while it is shown, store the token on 200 paired. Errors are ignored and the next poll runs. The pairing screen: ../watchapp/sync-and-pairing.md.

Garmin

The pairing request is sent form-encoded (no JSON content type); the server accepts both.

Wear OS

The pairing request is sent as JSON.

Unpairing

Where What happens on the server
Watch: POST /api/v1/devices/unpair with the bearer token Device with that token gets revoked_at = now and token_hash = null. Always 200 {"status":"unpaired"}, also without or with an unknown token.
Web: Unpair device on the device's edit page (/my/devices/:id/edit, ../server/devices.md) revoked_at = now. The token hash stays, but revoked devices fail authentication. The device disappears from the list.
Pairing to another account See Entering the code.

Client rule (both apps): Unpair device in the start menu deletes the token and the privacy consent on the watch only. Nothing is transmitted: neither app calls POST /devices/unpair (decided 2026-10-19), so the server still lists the device as paired and its old token stays valid until it is removed on the web or the watch pairs again. The queue is kept and uploaded only after a new consent and the next pairing.

Rate limits

All limits use RateLimitStore (Rails cache) and answer 429 when exceeded.

Endpoint Key Limit
POST /api/v1/devices/register IP 30 per hour
POST /api/v1/devices/pairing_requests IP 10 per 10 min
GET /api/v1/devices/pairing_requests/poll IP 120 per 10 min
POST /pair (web) user, else IP 5 per 5 min
POST /api/v1/tracks/segments device, else IP 120 per hour

Planned (not built)

  • ⏳ Connect IQ SDK for partner apps: client key header (X-SailEver-Client), partner registry, consent stored on the device, 403 client_suspended, deleting the queue on unpair or 401, pairing generations. Plan: ciq_sdk_plan.md.
  • ⏳ Apple Watch client.

Known deviations

  • Security: a pairing request for a device_uid that is already paired gets a token on the next poll, without anyone entering a code. poll_pairing_request only checks device.paired?. Anyone who knows a paired device's uid can take over its uploads and invalidate the real watch's token. Fix before going public.
  • Neither watch app stops polling on 410 expired. The screen keeps showing the stale code and polls every 5 s, which also uses up the 120 per 10 min poll limit per IP.