# Ravito: full guide for apps (and the coding agents that build them) Ravito is in beta: the protocol is v1 and may still change; we announce breaking changes and keep the SDK path versioned (/platform/sdk/v1/). Feedback welcome: via the developer page (/platform/ontwikkelaars.html). Units everywhere: distance and altitude in metres (m), speed in metres per second (m/s), power in watts (W), heart rate in beats per minute (bpm), cadence in revolutions per minute (rpm), time in seconds (s), lat and lon in degrees (WGS84). The short version is /platform/llms.txt. The server is https://ravito.online (a server that serves this file puts its own address here). Machine-readable API: /openapi/v1.json. Implementation-neutral specification: docs/ravito-protocol.md. Browser SDK (version 1): https://ravito.online/platform/sdk/v1/ravito.js. Riding the open shared route (works without the SDK, in any language): section 9. CORS is open: a browser app can call every API endpoint directly from its own origin (the response headers expose Retry-After and ETag). No proxy needed. ## Routes an app uses ROUTE POST /connect/register ROUTE GET /api/v1/account/me ROUTE GET /api/v1/profile ROUTE GET /api/v1/history/rides ROUTE POST /api/v1/history/rides ROUTE GET /api/v1/history/rides/{id} Also (not in the OpenAPI paths, they are OAuth endpoints): GET /connect/authorize, POST /connect/token, POST /connect/revoke, GET /.well-known/openid-configuration, and the WebSocket /api/v1/ws. ## The 20-line app A browser app that signs the rider in, shares its place on the shared route with the other riders on it, and checks a ride upload. Replace the client_id by the one /connect/register gave you (or use ravito-sandbox while testing on http://localhost:5173/callback). ```js example import { Ravito } from 'https://ravito.online/platform/sdk/v1/ravito.js'; const ravito = new Ravito({ server: 'https://ravito.online', clientId: 'rv_YOUR_CLIENT_ID', redirectUri: location.origin + '/', scope: 'presence profile:read rides:write-own', }); if (!(await ravito.finishLogin())) await ravito.login(); // the rider signs in and gives consent, then returns here else { ravito.onRiders((riders) => console.log('near you:', riders)); // [{ id, nick, lat, lon, speed, app, routeId, distance?, dir? }] (distance/dir only on a road your route shares) ravito.onError((e) => console.warn(e.code, e.hint)); // invalid_presence, off_route, implausible, rate_limited, ... await ravito.connectPresence({ place: 'mallorca-west' }); // presence exists only on a shared route ravito.sendDistance({ distance: 1200, speed: 8 }); // metres along the route; repeat about once a second (section 9) const check = await ravito.uploadRide({ start: new Date().toISOString(), points: [[0, 52.01, 5.98], [90, 52.02, 5.99]] }, { dryRun: true }); console.log(check.summary); // stored: false, nothing was saved } ``` SDK methods: `Ravito.register({ server, name, redirectUris, scope })` (once), `new Ravito({ server, clientId, redirectUri, scope })`, `login()`, `finishLogin()`, `accessToken()`, `connectPresence({ place })` (reconnects by itself and keeps retrying with a growing pause; failed attempts reach `onError`), `send({ lat, lon, speed, alt })`, `onRiders(cb)`, `onError(cb)`, `uploadRide(ride, { dryRun })`, `logout()`, `close()`, and for the shared route `joinRoute(id)` and `sendDistance({ distance, dir, speed })` (section 9). Errors are `RavitoError` with `status`, `code`, `message`, `hint`. Versions: the SDK lives at /platform/sdk/v1/ (the old /platform/sdk/ravito.js is the same file, cached for 5 minutes only). Inside v1 we only add things that break nothing; a breaking change would be v2 on its own path /platform/sdk/v2/, and v1 would keep working. ## 1. Register an app ``` curl -X POST https://ravito.online/connect/register -H 'content-type: application/json' \ -d '{"client_name":"Gruvetto","redirect_uris":["https://gruvetto.example/cb"],"scope":"presence profile:read rides:write-own"}' ``` Answer 201: {"client_id":"rv_...","client_id_issued_at":...,"client_name":"Gruvetto","redirect_uris":[...],"scope":"...","grant_types":["authorization_code","refresh_token"],"response_types":["code"],"token_endpoint_auth_method":"none","verified":false} - Only public PKCE clients: no secret, token_endpoint_auth_method "none" (or absent), grant_types only authorization_code and refresh_token, response_types only code. - client_name: 1 to 60 characters, no control characters; riders see it as plain text. It must be unique among all apps. Uniqueness and the reserved check compare a normalised form: case, spacing, punctuation and look-alike characters (0/o, 1/i/l, 3/e, 4/a, 5/s, 7/t, Cyrillic a/e/o/p/c/x/y) count as the same. Taken: 409 client_name_taken. Reserved: any name that contains "Ravito" or "Hollowride" in that normalised form ("R4vit0 Pro" too), or equals a built-in app name: 400 client_name_reserved. Nicknames of riders are different: they are not unique. - redirect_uris 1 to 5 (RFC 8252): (a) https://... anywhere; (b) loopback for desktop apps and games: http://127.0.0.1/cb or http://[::1]/cb. At sign-in the port may be any port (your app opens a free port and sends redirect_uri=http://127.0.0.1:49731/cb); scheme, host, path and query must be exactly the registered ones; (c) a private-use scheme in reverse-DNS form with ONE slash, such as com.example.app:/callback (the redirect_uri at sign-in must equal it exactly; no authority, so com.example.app://x is refused); (d) http://localhost:/... is accepted for local testing but the RFC discourages the name, so there the port never floats and the match is exact: prefer 127.0.0.1. Never accepted: other http hosts, wildcard (*), #fragment, user info, javascript: and other schemes, values over 200 characters. Matching is by exact comparison otherwise: a different path, query, host or scheme is refused with an error page and nobody is redirected (no open redirect). - scope: only profile:read, presence, rides:write-own, offline_access. world:read, rides:read, routes:write and ride are not available to a new app (error scope_not_allowed; nothing is silently lowered). The operator can verify an app and add scopes. - 10 successful registrations and 60 attempts (failed ones count) per hour per IP (429 registration_rate_limited with Retry-After); at most 300 apps without an owner; 10 apps per account when you register while signed in (the developer page /platform/ontwikkelaars.html lists and deletes your apps). Changing your registration: sign in at /platform/ontwikkelaars.html (the account that registered the app while signed in owns it), press "Wijzigen / Edit" and change the name or the redirect URIs; the same rules as above apply, client_id and the riders' consent stay. Changing the name or redirect URIs of a verified app makes it unverified again and removes the scopes the administrator added. This is a browser (cookie) action, there is no RFC 7592 registration endpoint (yet). An app registered without being signed in has no owner and cannot be changed: register again. An ownerless app that no rider has authorized is removed after 1 to 2 days (until then its name stays taken: 409 client_name_taken); register again with the same name then. Becoming "verified": an unverified app shows "unverified" to riders on the consent screen and has the lower limits. The owner presses "Verificatie aanvragen / Request verification" on the developer page; an administrator of the Ravito server decides (by hand, no promised time) and may add scopes. Verified gives: 10 presence messages per second instead of 2, 10 sockets per rider instead of 3, the "verified" status on the consent screen, and extra scopes only if the administrator names them. It does not give higher registration limits, other speed limits or any access to other riders' heart rate or power. A blocked app (abuse) loses all tokens at once. ## 2. Sign a rider in OAuth 2.1 authorization code with PKCE S256. Take every URL from /.well-known/openid-configuration. 1. Make a random code_verifier (43 to 128 characters) and code_challenge = base64url(SHA-256(verifier)). 2. Send the rider's browser to /connect/authorize?client_id=...&redirect_uri=...&response_type=code&scope=presence%20profile:read%20offline_access&state=...&code_challenge=...&code_challenge_method=S256 3. The rider signs in and sees the consent screen. Your app is marked "Niet geverifieerde app / Unverified app" with your name and redirect host. Back on your redirect_uri with ?code=...&state=... (or ?error=access_denied). 4. POST /connect/token (form) grant_type=authorization_code, code, redirect_uri, client_id, code_verifier. Answer: access_token (10 minutes) and, with offline_access, refresh_token. 5. Refresh with grant_type=refresh_token, refresh_token, client_id. Every refresh gives a new refresh token; the old one dies. Offering an old one again revokes the whole authorization. Lifetimes: the authorization code lives 60 seconds and works once; the access token 10 minutes (expires_in: 600); the refresh token 30 days (only issued when you asked for offline_access; each refresh hands out a new one). Refresh before the access token expires, or when a call answers 401, and store the NEW refresh token every time: ``` curl -X POST https://ravito.online/connect/token -d grant_type=refresh_token -d client_id=rv_YOUR_CLIENT_ID -d refresh_token=OLD_REFRESH_TOKEN {"access_token":"...","token_type":"Bearer","expires_in":600,"refresh_token":"NEW_REFRESH_TOKEN","scope":"..."} ``` invalid_grant on a refresh means the link is gone (revoked by the rider, 30 days unused, or an old token was offered again more than 10 seconds after it was replaced; so store the new refresh token before you use it): send the rider through the sign-in again. The access token is checked only when a socket opens: an open socket keeps working after it expires and closes only on revoke, logout or 1008; refresh only to reconnect. Send Authorization: Bearer on every call. 401 = token bad, expired (after 10 minutes) or revoked; 403 = the token's scopes do not open this route. Scopes: profile:read (name, language, the profile with weight, FTP, heart rates and birth year, shared settings), presence (the socket, presence only), rides:write-own (upload a ride), offline_access (refresh token). A new app asks only for these four. The discovery document lists more scopes (openid, ride, world:read, ...) because the server knows them; an unverified app that asks for one gets scope_not_allowed. Discovery says token_endpoint_auth_methods_supported includes "none": your app is a public client, send client_id and code_verifier, no secret. GET /api/v1/profile (profile:read, read only) answers the rider's own profile, e.g. (camelCase): ```json {"profile":{"riderKg":78,"bikeKg":8.5,"totalKg":86.5,"ftp":250,"restingHr":55,"thresholdHr":168,"maxHr":185,"targetHr":140, "minPower":70,"maxPower":320,"cdA":0.32,"crr":0.0045,"trainerDifficulty":1,"birthYear":1980, "maxHrSource":"Entered","ftpSource":"Test","restingHrKnown":true,"hrZoneMethod":"Karvonen","maxPowerManual":true, "profileConfirmedAt":"2026-11-01T10:00:00Z"}, "confirmed":true,"pending":false, "limits":{"minPowerCap":..,"maxPowerCap":..,"defaultMaxPower":..,"maxTargetHr":..,"session":{...}}, "zones":{"method":"Karvonen","hr":[...],"power":[...]}} ``` Weight is riderKg (rider only) and bikeKg; totalKg is their sum, so use totalKg as the mass in section 9. birthYear is null when not given. A value the rider never filled in is the default shown above and "confirmed" is false: ask the rider before relying on ftp or maxHr. `limits` and `zones` are the rider's trainer limits and training zones (use them, do not recompute). The other profile routes (PUT, apply-test) need the rider's own browser session: an app cannot change the profile. GET /api/v1/shared/settings (profile:read) answers the rider's sharing settings (visible, nickname, ...); it reads only, an app cannot switch visibility on. GET /api/v1/account/me answers {id, name, stravaConnected, termsOk, termsVersion, lang} for an OAuth app (profile:read). Fields about the server (isLocal, isAdmin, ...) are not part of it. On a server run in Local mode (a rider's own PC) there is no login: /connect/authorize shows the consent page at once; the allow button is `button[name=decision][value=allow]` (deny is value=deny). A server with accounts asks the rider to sign in first. ## 3. Presence Open wss://ravito.online/api/v1/ws with the access token. Use the subprotocol "ravito.bearer.": `new WebSocket(url, "ravito.bearer." + token)`. This is the only form that works with the global WebSocket of browsers and Node (neither can set headers); the server accepts it back. Only a library that can set headers (npm `ws`, Python `websockets` with additional_headers, Unity ClientWebSocket.Options.SetRequestHeader) may send the header `Authorization: Bearer ` instead; if both are present, the header wins. The SDK uses the subprotocol. One JSON message per frame. A bad or revoked token is 401 before the upgrade. Units in every message: distance m, speed m/s, lat/lon degrees WGS84. Send about once a second: ```json { "type": "presence", "place": "mallorca-west", "distance": 1234.5, "dir": 1, "speed": 8.1 } ``` Presence exists only on a shared route: `place` is required and must be the id of a shared route (section 9; now "mallorca-west"). You give `distance` (metres along the route) or GPS `lat`/`lon` within 30 m of it; speed 0..33 m/s (118.8 km/h), dir 1 or -1 (section 9.3). There is no free lat/lon presence anywhere else: no rooms of your own and no "riders near you". Extra fields are ignored and never passed on. Reply to every valid message: ```json { "type": "riders", "mode": "on", "riders": [ { "id": "r_3f9a1c07b2de", "nick": "anna", "lat": 39.7612, "lon": 2.7023, "speed": 7.9, "app": "Gruvetto", "distance": 1210.4, "dir": 1, "routeId": "mallorca-west" } ] } ``` - You see riders who chose to be visible (with a nickname), only if you are visible yourself too; a hidden rider (mode "hidden", empty list) sees nobody and is seen by nobody. - How a rider becomes visible: riders are visible by default, under their own nickname or an automatic one ("Rider 4821"; never the username), and an app cannot change that for them. A rider who does not want it signs in to Ravito (https://ravito.online/platform/samen.html) and unticks "Zichtbaar voor anderen" (visible to others). While it is off every answer is {"type":"riders","mode":"hidden","riders":[],"hint":"..."}. In a test, use the sandbox: it shows three bots without any setting. - A socket with only the scope presence receives nothing but these replies and errors: never the rider's own ride state (heart rate, power, target power), results or control messages. Ignore message types you do not know. - Riders arrive only as the reply to your own presence message. Keep sending about once a second also while paused (speed 0): if you stop, the list stops updating. - Riders in the same shared world (all its routes). At most 30 per reply, nearest first. Each rider has id, nick, lat, lon, speed, app, routeId and, only on a road your route shares, distance and dir; never heart rate, power, target power or user name. - Errors come as {"type":"error","code","message","hint"}: invalid_presence (value out of range or wrong type; the hint shows a valid message), off_route (GPS lat/lon more than 30 m from the route: refused, nothing is rounded onto the route), implausible (a position that does not follow plausibly from the previous one of this connection, or speed over 33 m/s: ignored; 5 in a minute close the socket with code 1008), rate_limited (over 2 messages per second for an unverified app: ignored; repeated, the socket closes), too_many_connections (3 per rider for an unverified app). - If the rider revokes your app (account page, "Gekoppelde apps") or the operator blocks it, the socket closes and the rider disappears from the others' lists. Treat a close as "sign in again". - Presence is display only: it never counts for segment times, PRs, KOMs or rankings. Node (22+, no SDK; `ws` package or the global WebSocket of Node 22 with the subprotocol). Get `token` through section 2 first. ```js const ws = new WebSocket('wss://ravito.online/api/v1/ws', ['ravito.bearer.' + token]); let distance = 1000; // metres along mallorca-west (section 9 has the whole loop) ws.onopen = () => setInterval(() => ws.send(JSON.stringify({ type: 'presence', place: 'mallorca-west', distance: (distance += 8), speed: 8 })), 1000); ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.type === 'riders') console.log(m.mode, m.riders, m.hint); else if (m.type === 'error') console.warn(m.code, m.hint); }; // PKCE without the SDK: verifier = base64url(crypto.randomBytes(32)); challenge = base64url(sha256(verifier)); open the authorize URL (section 2), // listen on your redirect_uri (http://127.0.0.1:/cb) with node:http, then POST /connect/token (form) with the code and verifier. ``` ## 4. Upload a ride ``` curl -X POST 'https://ravito.online/api/v1/history/rides?dry_run=1' -H 'authorization: Bearer TOKEN' -H 'content-type: application/json' \ -d '{"start":"2026-11-07T09:00:00Z","name":"Morning ride","points":[[0,52.0100,5.9800,41,180,132],[120,52.0110,5.9810,42,190,135]]}' ``` - points: arrays `[t, lat, lon, alt?, watt?, hr?, cadence?, speed?]`. Units: t seconds (s) relative to `start` (the first point normally has t 0; non-decreasing; the ride is stored as running from your first point, so a first t above 0 is not kept as a pause), lat and lon degrees WGS84 (-90..90, -180..180), alt metres -500..9000, watt W 0..2500, hr bpm 30..250, cadence rpm 0..250, speed m/s 0..70. Required: t, lat, lon. Optional: everything after lon; leave the array shorter, or write null for one you skip (`[12, 52.01, 5.98, null, null, null, 88]` is a cadence without altitude, power and heart rate). At most 8 values, all numbers or null; anything else is 400 invalid_ride with the point number. - Cadence and speed are stored. Without speed, the speed of a sample is worked out from the distance between points; without cadence the sample has no cadence. No separate object form: arrays only. `start` is ISO 8601, not older than the year 2000 and not more than a day in the future. - At least 60 seconds, at most 12 hours, 50 000 points and 5 MB. Otherwise 400 invalid_ride or ride_too_large with a hint. - Without dry_run: 201 {"stored":true,"id":...,"source":"external","clientId":"rv_...","notCountingReason":"extern, niet gevalideerd","samplesKeptMonths":24,"summary":{...}}. With dry_run: 200 and "stored":false. - The ride belongs to the signed-in rider, is listed in their ride list with your app's name, and never counts for a segment, PR, KOM or ranking. Power and heart rate in it are visible to them only. - Retention: the points (samples) of a ride that ended more than 24 months ago are removed every night; the ride row stays (the FIT export is then gone) and no longer counts for the 1000-hour quota. So uploading a longer history keeps only its ride rows. The 201 answer has samplesKeptMonths. - The same start from the same app twice: 409 duplicate_ride. Deleting the account deletes the uploaded rides. - GET /api/v1/history/rides (scope rides:read, not available to a new app) lists rides; riders see all their rides in the Ravito ride list. ## 5. Error codes Every error of these routes and of the socket is {"code","message","hint"} (the socket adds "type":"error"). code is stable, hint says what to do; message is Dutch today. - client_name_taken (409), client_name_reserved (400): see client_name above. - invalid_client_metadata: registration is not a JSON object, bad name, a secret or other grant or response types. - invalid_redirect_uri: missing, more than 5, not https / loopback http (127.0.0.1, [::1]) / reverse-DNS private-use scheme, wildcard or fragment. - client_blocked (403): the app is blocked and cannot be changed. nothing_to_verify (409): verification asked for an app that is already verified or blocked. - scope_not_allowed: a scope a new app may not ask for, or an unknown scope. The hint lists the allowed scopes. - registration_rate_limited: more than 10 registrations or 60 attempts per hour per IP. Retry-After says when. - too_many_clients: the account already has 10 apps. - unknown_client: no such app (developer routes). - config_client: the app comes from the server configuration and cannot be blocked or verified this way (operator routes). - invalid_presence: a presence value is out of range or has the wrong type; on a shared route also a distance outside 0..lengthM, a dir other than 1 or -1, or a place that is missing or is not a shared route (presence exists only on a shared route). - not_sharing: open presence (mode "open", section 10b) from a rider who has not turned on "Always share my position" in the Ravito settings. Your app cannot turn it on; send the rider to /platform/samen.html. - off_route: GPS lat/lon more than 30 m from the line of the shared route (refused; nothing is rounded onto the route). The hint says how far you are. - unknown_route (404): GET /api/v1/routes/{id} with an id that is not in /api/v1/shared-routes. - unknown_format (400): GET /api/v1/routes/{id}?format=... other than gpx. - implausible: an impossible position jump or a speed over 33 m/s (118.8 km/h; 33 m/s leaves room for a long simulated descent). Always answered with a message, never dropped silently. - invalid_presence is also the answer to a message that is not a presence report at all (not JSON, wrong "type", a worldId message from an OAuth app): ignored for everything else, but you are told. - rate_limited: too many presence messages. - too_many_connections: more connections than your app may have for one rider. - invalid_ride: a missing or wrong field in a ride, a ride shorter than 60 seconds, a point out of range. - ride_too_large: more than 5 MB, 50 000 points or 12 hours. - duplicate_ride: this start of this app is already stored (409). - ride_quota: the rider holds the maximum of 200 hours of uploaded rides (429); they delete old ones. - upload_budget: the platform's daily upload budget is used up (503); try again tomorrow. ## 6. Test in the sandbox Client id ravito-sandbox, scopes profile:read, presence, rides:write-own. Redirect URIs: only http://localhost:/ or http://localhost:/callback (also 127.0.0.1) for ports 3000, 3001, 4000, 4173, 4200, 5000, 5173, 5174, 5500, 8000, 8080, 8081, 8888, 9000. Your presence messages (on mallorca-west) are answered with three bot riders (bot-1 to bot-3) that ride the route ahead of and behind you, with distance, lat and lon; a page opened as file:// has no valid redirect URI, so serve it from a local server on one of these ports (for example `npx serve -l 5173` or `python -m http.server 8000`); you appear to other sandbox riders as proefrijder-xxxx and no real rider ever sees you or is seen by you. A ride upload is validated and answered with "stored":false,"sandbox":true; nothing is saved. ## 6b. Two riders, and long workouts Two visible riders without a browser: sign in twice with the sandbox client using two different accounts (two sign-ups on the server) and open one socket each; both send presence on mallorca-west and each sees the other as proefrijder-xxxx plus the three bots. The same account on two sockets does not see itself. With a real (non-sandbox) app a rider only becomes visible after he ticks "Zichtbaar voor anderen" and fills in a nickname at /platform/samen.html; no API does that for him (GET /api/v1/shared/settings shows the state), so a test with your own app answers mode "hidden" until he does. Token expiry: the access token lasts 10 minutes, but it is checked only when the socket opens. An open socket does NOT close when the token expires; it ends only on revoke (the rider removes your app), logout, five implausible positions (1008) or a network drop. So an hour-long workout needs no refresh while the socket stays open. After a drop, reconnect with a fresh access token: refresh first (scope offline_access, section 2), then open the socket again and continue with the same place and distance. A refresh token lasts 30 days and each use returns a new one: store the new one every time. ```js let tokens; // {access_token, refresh_token} from sign-in (scope includes offline_access) async function refresh() { const r = await fetch(base + '/connect/token', { method: 'POST', body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: tokens.refresh_token, client_id: CLIENT }) }); if (!r.ok) throw new Error('sign in again'); // revoked, or an old refresh token was offered twice tokens = await r.json(); // keep the NEW refresh_token } async function connect() { await refresh(); // a fresh access token before every (re)connect const ws = new WebSocket(base.replace('http', 'ws') + '/api/v1/ws', 'ravito.bearer.' + tokens.access_token); ws.onclose = e => { if (e.code !== 1008 && running) setTimeout(connect, 2000); }; // 1008: stop, do not loop return ws; } ``` ## 7. Safety of your own trainer control Ravito has no trainer API. It never sends a target power, a device command, a start or a stop to your app or to any rider. It never shares heart rate, power or target power of one rider with another, and a presence message of someone else cannot be, or become, a target power. If your app controls a smart trainer, YOUR app is responsible for doing that safely. Recommended practice: 1. Never a high target power without a valid heart rate. 2. Heart-rate loss, reaching the maximum heart rate, pause and stop all mean minimum power. 3. A simulated heart rate never drives a real trainer. 4. The position or presence of other riders never decides a target power. ## 8. Data and privacy Apps only get what the rider consented to on the consent screen. Positions are shared only between riders who are both visible, and revoking an app removes the rider at once. Strava data belongs to the rider and is never part of this API. World bundles carry an attribution list that an app must show. ## 8b. Differentiate, but ride together DO make your app different: style, purpose and audience are yours (workout app, racing game, coaching, casual ride, VR, 2D map, minimal trainer app). Make the shared world (Mallorca) the default for workouts and free rides; let riders opt out. DO let your riders ride the shared route together: `mallorca-west` today, more routes coming. A busy shared map only exists when many apps join it. - When a rider starts a workout (ERG, intervals), a free ride or a race, offer "ride it on the shared route" (a toggle or a button is enough) and, when accepted, join presence with place = the route id (section 9). - Show the other riders your own way (a dot on a map, an avatar, a ghost, a name tag, a list). Take them from the `riders` reply; they come from every app. - Do not wall your riders in: an app that keeps its riders on private routes never shows them anyone. - Keep the rider's choice: visibility is the rider's setting at /platform/samen.html; handle "mode":"hidden". ## 8c. Accounts: your app account and the rider's Ravito account Recommended: "Sign in with Ravito" (OAuth 2.1 PKCE, section 2), either as the ONLY login of your app (no passwords of your own to keep safe) or as a LINK on an existing app account (the rider keeps your login and adds Ravito for profile, rides and the shared route). Facts, checked against the server: - A public app cannot read the access token or introspect it (the token is opaque; introspection is only for trusted server clients). To learn who the rider is, call GET /api/v1/account/me with the access token (scope profile:read). It answers JSON with `id` (the rider's stable Ravito user id, a GUID string; the same value as `sub` in the introspection answer a trusted server gets) and `name` (display name). The other fields (termsOk, termsVersion, lang, stravaConnected) are not an identity. - Store `id`. Never use `name` as a key: it may change. - Unlink: POST /connect/revoke (form) token=, token_type_hint=refresh_token, client_id=; answer 200, also for a token that is already dead. This revokes the authorization (an open socket closes too). Then delete the stored tokens and the `id`. - Revoked from Ravito's side (the rider removes your app on the account page): the next refresh answers HTTP 400 with {"error":"invalid_grant"}; treat that as unlinked (keep the app account, drop the link, offer "link again"). Rules: one Ravito account per app account (and one app account per Ravito id: refuse a second link with a clear message); never ask for or see the Ravito password (sign-in happens on Ravito's own page); data minimisation: request only the scopes you need (presence for the shared route; profile:read only to read the profile or `id`; rides:write-own only to upload; offline_access only when you refresh). Pseudocode: ``` on "Sign in with Ravito" / "Link Ravito": tokens = pkce_login(scope="presence profile:read offline_access") # section 2 me = GET /api/v1/account/me (Bearer tokens.access_token) if another_app_account_has(ravito_id = me.id): POST /connect/revoke token=tokens.refresh_token ...; show "already linked to another account"; stop if ravito_is_only_login: account = find_or_create_account(ravito_id = me.id) else: current_app_account.ravito_id = me.id # link store(account.ravito_id = me.id, account.refresh_token = tokens.refresh_token) on "Unlink Ravito": POST /connect/revoke token=refresh_token token_type_hint=refresh_token client_id=... delete account.ravito_id, account.refresh_token on refresh: r = POST /connect/token grant_type=refresh_token ... if r.error == "invalid_grant": delete account.ravito_id, account.refresh_token # revoked by the rider else: store the NEW refresh token ``` ## 9. Ride the shared route (any language, no SDK needed) Open routes in one shared world, Mallorca, that apps of every kind ride together: `mallorca-west` (Serra de Tramuntana; 51 266 m, 1 042 m climbing; the end is 12 m from the start, so you ride it as a lap), `mallorca-soller-kort` (a short lap through Sóller; 3 046 m, 48 m), `mallorca-coll-de-soller` (Sóller to the Coll de Sóller and back; 27 581 m, 741 m) and `mallorca-deia` (Sóller to Deià and back; 23 682 m, 472 m). The short routes ride the same roads as the lap for most of the way, and riders on any of them see each other there (9.3). Your app picks one id; there is no world id to send. The apps: an ERG app, a workout app, a GPS app, a 3D world. Ravito gives the line, the profile and the other riders. Your app works out its own speed and distance. The Ravito server calculates nothing; it only checks that a position is plausible. It never sends anything to a trainer. Human-readable version with a diagram: /platform/docs.html#route. With the browser SDK this is two lines (see 9.6); everything here works without it, in any language. ### 9.1 Who does what | Your app | Ravito | |---|---| | gets the route (once) and the profile | serves the line, GeoJSON or GPX, and the profile every 50 m (open data, no sign-in) | | measures power (or any speed source) | nothing: it never sees your power | | works out speed (reference model, 9.4) and adds up the distance | nothing: it does not calculate for you | | reports `distance` about once a second | checks plausibility, works out lat/lon, keeps a list per place | | shows the other riders however it wants (map, list, 3D) | gives you the visible riders of all apps, nearest first, at most 30 | | controls its own trainer (ERG, slope, whatever it does) | never controls a trainer; no message of another rider is ever a target power | ### 9.2 The route API (open data) ``` GET /api/v1/shared-routes {"routes":[{"id":"mallorca-west","name":"Mallorca – Serra de Tramuntana","lengthM":51266.1,"ascentM":1042,"attribution":"...","license":"...","url":"/api/v1/routes/mallorca-west","place":"mallorca-west","world":"mallorca","riders":4}, {"id":"mallorca-coll-de-soller","name":"Mallorca – Coll de Sóller","lengthM":27581.3,"ascentM":741,...,"world":"mallorca","riders":7}, ...]} GET /api/v1/routes/mallorca-west (application/geo+json, about 600 KB) {"type":"Feature","id":"mallorca-west","properties":{"id":"mallorca-west","name":"...","lengthM":51266.1,"ascentM":1042,"attribution":"...","license":"...","distances":[0,5,10,...,51266.1]}, "geometry":{"type":"LineString","coordinates":[[2.715185,39.766503,33.7],[2.715127,39.766499,33.6],...]}} GET /api/v1/routes/mallorca-west?format=gpx (application/gpx+xml, trkpt lat lon ele) GET /api/v1/routes/mallorca-west?profile=1 {"id":"mallorca-west","name":"...","lengthM":51266.1,"ascentM":1042,"attribution":"...","license":"...", "columns":["distanceM","altitudeM","gradePercent"],"stepM":50,"profile":[[0,33.7,-2.66],[50,33.7,3.04],[100,34.8,1.15],...,[51266.1,34,1.18]]} ``` - GeoJSON follows RFC 7946: coordinates are [lon, lat, altitude m]; the point i is `properties.distances[i]` metres from the start (a point every 5 m). Grade is in percent, positive uphill (the profile is every 50 m, the last row is at lengthM). - No sign-in, no token. Route answers carry `ETag` (hash of the content) and `Cache-Control: public, max-age=3600, s-maxage=86400`; send `If-None-Match` and you get 304. - The list: `world` is a group label only (you never send it); `riders` is how many visible riders (all apps, no sandbox) ride that route now, at most 5 s old, a number only. Show it in your route choice ("Coll de Sóller · 7 riding"). The list is cached for 3 s (`Cache-Control: public, max-age=3`). - Errors: 404 `{"code":"unknown_route","message","hint"}` for an id that is not in the list; 400 `unknown_format`. - The data is open: the line is (c) OpenStreetMap contributors (ODbL; `mallorca-west` via BRouter), the altitude is (c) IGN/CNIG MDT05 5 m (CC BY 4.0). `attribution` and `license` are in every answer, per route; show the attribution of the route you ride to your users. ### 9.3 The messages Socket and sign-in are as in sections 2 and 3 (scope presence; wss://ravito.online/api/v1/ws; browsers use the subprotocol `ravito.bearer.`). You send, about once per second (an unverified app at most 2 per second, a verified one 10): ```json { "type": "presence", "place": "mallorca-west", "distance": 1234.5, "dir": 1, "speed": 4.11 } ``` - `place` "mallorca-west": the route you ride (any id from the list). - `distance`: metres along that route from its start, 0 to its lengthM (51266.1 for mallorca-west), a number; required in this form (outside the range: `invalid_presence`). - `dir`: 1 (default, forward along the line) or -1 (riding the route the other way round); any other value is `invalid_presence`. - `speed`: m/s, 0 to 33 (118.8 km/h), optional. - Other fields are ignored and never passed on. The server works out lat, lon and altitude from the line. - Alternative for GPS apps: `{"type":"presence","place":"mallorca-west","lat":39.76,"lon":2.71,"speed":8}`. Within 30 m of the line the server snaps you to a distance (the others see it as `distance`; your lat/lon stays as you sent it). Further away it is refused with `{"type":"error","code":"off_route","message","hint"}`: nothing is rounded onto the route, and there is no presence off the route. - `place` missing, or any id that is not a shared route: `invalid_presence`. A message without `distance` and without lat/lon: `invalid_presence`. You get back, to every valid message: ```json { "type": "riders", "mode": "on", "riders": [ { "id": "r_3f9a1c07b2de", "nick": "anna", "lat": 39.7612, "lon": 2.7023, "speed": 7.9, "app": "Example Bike", "distance": 1210.4, "dir": 1, "routeId": "mallorca-west" }, { "id": "r_b81e55d0a9c4", "nick": "bert", "lat": 39.7003, "lon": 2.6411, "speed": 8.1, "app": "Gruvetto", "distance": 31200.5, "dir": -1, "routeId": "mallorca-west" } ] } ``` - Every rider always has id, nick, lat, lon, speed, app and routeId (the route that rider rides). `distance` (m, forward line of YOUR route) and `dir` are present only when that rider is on a road your route shares (next point); for riders elsewhere in the world they are ABSENT (not null, not 0): check for them before use (JavaScript `r.distance != null`, Python `r.get('distance')`), and place such a rider by lat/lon. Ignore fields you do not know. - Riders of other routes: every visible rider of the same world is in your list (also where the routes part, for example the loop through Deià seen from mallorca-west), and you are in theirs. Where another route runs on the same road as yours (within 30 m, stretches of 50 m or more) their `distance` and `dir` are projected onto your route, so you place them exactly like riders of your own route; where it does not, `distance` and `dir` are absent and you use `lat`/`lon` (their own position). `routeId` says which route they ride. Where your route rides a road twice (out and back), they are put on the pass you ride in their direction. Nearest first: along your route on shared road, otherwise by straight-line distance. - Switching route: send another `place` (the SDK: `joinRoute('', { distance })` again). Allowed on the same connection, but the jump rule on the map still holds across the switch: switch where both routes share the road, at the same spot, never to a place kilometres away (`implausible`). `id` is an opaque string such as "r_3f9a1c07b2de": see "Rider ids" below. `lat`/`lon`: degrees. `speed`: m/s as that app reported it. - All visible riders of all apps are in the one list, in both directions ( dir -1 is a rider on the reverse route, their distance is measured on the forward line). Nearest first, at most 30. Visibility is the rider's own choice (section 3); mode "hidden" with an empty list and a hint means you yourself are not visible. - Never heart rate, power, target power, user name or client id of anybody. - Jump rule: a new position must follow plausibly from the previous one of the same connection: at most 33 m/s times the seconds since then plus 50 m, measured along the route (the end of the lap joining the start is not a jump) and on the map. The first report after connecting is free. A bigger jump, or a speed above 33 m/s, is ignored with `{"type":"error","code":"implausible","message","hint"}`; five in a minute close the socket (code 1008). You cannot scan the route by jumping: report real riding. Also `rate_limited`, `too_many_connections`, `invalid_presence`, `off_route` (section 5). - Riders in the list never decide anything for your trainer, and presence never counts for segment times, PRs, KOMs or rankings. ### 9.4 The reference bike model (Martin et al. 1998) Optional: your app may use its own speed source or model. This is the one the docs and the SDK use as a starting point. With P = power (W), m = total mass (kg, rider plus bike), grade g in percent, a = atan(g / 100), v = speed (m/s): P = v * ( m*9.81*sin(a) + m*9.81*Crr*cos(a) + 0.5*rho*CdA*v^2 ) Crr 0.0045, CdA 0.32 m2, rho 1.225 kg/m3 The right side only grows with v, so solve for v by bisection between 0 and 40 m/s (60 halvings is plenty). Every second: grade at your distance (interpolate the profile), v from the model, `distance = distance + dir * v * dt`, wrap round at lengthM (and below 0). An app may add inertia and cornering on top; you do not need to. Check your implementation: 200 W and 85 kg give 4.11 m/s (14.8 km/h) at 5 % and 9.43 m/s (34.0 km/h) at 0 % (within 0.02 m/s); after 10 s at 5 % you are 41 m further. Whole-route check: 60 s at 200 W and 85 kg from distance 0, in 1 s steps with the grade looked up at the start of each step, ends at about 632 m (accept 600 to 665). ### 9.5 Complete examples without the SDK Node 22+ (global WebSocket and fetch), 60 virtual seconds at 200 W. `token` comes from section 2. ```js const base = 'https://ravito.online', ROUTE = 'mallorca-west'; // or any id from GET /api/v1/shared-routes, e.g. 'mallorca-deia' const rows = (await (await fetch(base + '/api/v1/routes/' + ROUTE + '?profile=1')).json()).profile, L = rows.at(-1)[0]; // [distance, altitude, grade %] every 50 m const grade = (d) => { const i = Math.min(Math.floor(d / 50), rows.length - 2), [d0, , g0] = rows[i], [d1, , g1] = rows[i + 1]; return g0 + (g1 - g0) * (d - d0) / (d1 - d0); }; function speed(P, m, g) { // reference model, section 9.4 const a = Math.atan(g / 100), need = (v) => v * (m * 9.81 * Math.sin(a) + m * 9.81 * 0.0045 * Math.cos(a) + 0.5 * 1.225 * 0.32 * v * v); let lo = 0, hi = 40; for (let i = 0; i < 60; i++) { const mid = (lo + hi) / 2; need(mid) < P ? lo = mid : hi = mid; } return (lo + hi) / 2; } const ws = new WebSocket('wss://ravito.online/api/v1/ws', ['ravito.bearer.' + token]); ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.type === 'riders') console.log(m.riders.map((r) => [r.nick, r.app, r.distance, r.lat, r.lon])); else if (m.type === 'error') console.warn(m.code, m.hint); }; ws.onopen = () => { let distance = 0, seconds = 0; const timer = setInterval(() => { const v = speed(200, 85, grade(distance)); // your app's power (W) and mass (kg) go here distance = (distance + v) % L; // dt = 1 s ws.send(JSON.stringify({ type: 'presence', place: ROUTE, distance: Math.round(distance * 10) / 10, dir: 1, speed: Math.round(v * 100) / 100 })); if (++seconds === 60) { clearInterval(timer); ws.close(); console.log('after 60 s:', distance); } }, 1000); }; ``` Python 3.9+ (`pip install requests websockets`), the same ride. Sign-in with the sandbox client (section 6); a server run on a rider's own PC (Local mode) shows the consent page at once; elsewhere it appears the first time only, later sign-ins with the same scopes redirect straight back. ```python import asyncio, base64, hashlib, json, math, os, secrets, urllib.parse, webbrowser, requests, websockets from http.server import BaseHTTPRequestHandler, HTTPServer BASE, CLIENT, REDIRECT = 'https://ravito.online', 'ravito-sandbox', 'http://localhost:5173/callback' ROUTE = 'mallorca-west' # or any id from GET /api/v1/shared-routes, e.g. 'mallorca-deia' def sign_in(): # OAuth 2.1 code + PKCE S256 (section 2) verifier = base64.urlsafe_b64encode(os.urandom(32)).rstrip(b'=').decode() challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b'=').decode() state, got = secrets.token_urlsafe(16), {} class Catch(BaseHTTPRequestHandler): def do_GET(self): got.update(urllib.parse.parse_qsl(urllib.parse.urlsplit(self.path).query)) self.send_response(200); self.end_headers(); self.wfile.write(b'You can close this tab.') def log_message(self, *args): pass server = HTTPServer(('localhost', 5173), Catch) url = BASE + '/connect/authorize?' + urllib.parse.urlencode({'client_id': CLIENT, 'redirect_uri': REDIRECT, 'response_type': 'code', 'scope': 'presence profile:read', 'state': state, 'code_challenge': challenge, 'code_challenge_method': 'S256'}) print('Open and allow:', url); webbrowser.open(url) server.handle_request() # waits for the redirect with ?code=...&state=... assert got.get('state') == state, got r = requests.post(BASE + '/connect/token', data={'grant_type': 'authorization_code', 'code': got['code'], 'redirect_uri': REDIRECT, 'client_id': CLIENT, 'code_verifier': verifier}) return r.json()['access_token'] rows = requests.get(BASE + '/api/v1/routes/' + ROUTE, params={'profile': 1}).json()['profile'] # [distance, altitude, grade %] every 50 m L = rows[-1][0] def grade(d): i = min(int(d // 50), len(rows) - 2); (d0, _, g0), (d1, _, g1) = rows[i], rows[i + 1] return g0 + (g1 - g0) * (d - d0) / (d1 - d0) def speed(power, mass, g, cda=0.32, crr=0.0045, rho=1.225): # reference model, section 9.4 a = math.atan(g / 100) need = lambda v: v * (mass * 9.81 * math.sin(a) + mass * 9.81 * crr * math.cos(a) + 0.5 * rho * cda * v * v) lo, hi = 0.0, 40.0 for _ in range(60): mid = (lo + hi) / 2 lo, hi = (mid, hi) if need(mid) < power else (lo, mid) return (lo + hi) / 2 async def main(): token = sign_in() async with websockets.connect(BASE.replace('http', 'ws', 1) + '/api/v1/ws', subprotocols=['ravito.bearer.' + token]) as ws: async def listen(): async for raw in ws: m = json.loads(raw) if m['type'] == 'riders': print([(r['nick'], r['app'], r.get('distance'), r['lat'], r['lon']) for r in m['riders']]) elif m['type'] == 'error': print(m['code'], m['hint']) listener = asyncio.create_task(listen()) distance = 0.0 for second in range(60): v = speed(200, 85, grade(distance)) distance = (distance + v) % L # dt = 1 s await ws.send(json.dumps({'type': 'presence', 'place': ROUTE, 'distance': round(distance, 1), 'dir': 1, 'speed': round(v, 2)})) await asyncio.sleep(1) listener.cancel() print('after 60 s:', distance) # about 632 m asyncio.run(main()) ``` ### 9.6 With the browser SDK, and the workout app ```js import { Ravito } from 'https://ravito.online/platform/sdk/v1/ravito.js'; const ravito = new Ravito({ server: 'https://ravito.online', clientId: 'rv_YOUR_CLIENT_ID', redirectUri: location.origin + '/', scope: 'presence profile:read' }); if (!(await ravito.finishLogin())) await ravito.login(); else { const p = await (await fetch('https://ravito.online/api/v1/profile', { headers: { Authorization: 'Bearer ' + await ravito.accessToken() } })).json(); const mass = p.profile?.totalKg ?? 85; // the rider's own weight; 85 only as a fallback const ride = await ravito.joinRoute('mallorca-west'); // route + profile, socket, place setInterval(() => ride.update({ power: currentWatts(), mass }), 1000); // speed, distance and the report to Ravito ride.onRiders((riders) => draw(riders)); // or read ride.others and ride.position { distance, lat, lon, alt, grade } } ``` Example: a workout app (ERG, intervals) lets the rider ride along virtually on mallorca-west during the workout. The app keeps controlling its trainer in ERG by its own workout plan, as before; the real power the trainer measures goes through the bike model to speed and distance, and Ravito only shows the position to the others. Nothing flows back into the trainer: the other riders and the route never change the target power. ```js const ride = await ravito.joinRoute('mallorca-west'); workout.onTick(({ targetWatts, measuredWatts }) => { // once per second, in your workout loop trainer.setErgTarget(targetWatts); // your app, your plan: unchanged, nothing from Ravito goes into this line ride.update({ power: measuredWatts, mass }); // mass = profile totalKg (or riderKg + bikeKg), 85 only as a fallback; the measured power becomes speed and distance, and the report to Ravito }); ``` Take the mass from GET /api/v1/profile (`totalKg`, or `riderKg` + `bikeKg`) and use 85 only when that fails or `confirmed` is false. `ride.update({ power, mass, dt })`: `dt` (seconds) is optional. Without it the SDK uses the time since the previous call; a gap over 2 s counts as a pause (dt = 0), so the rider never jumps after a pause. Extra helpers: `ride.pointAt(distance)` gives `{lat, lon, alt, grade}` from the route line, `ride.geojson` is the GeoJSON route itself (no second fetch), and `ride.predict(rider, msSinceUpdate)` returns `{id, distance, lat, lon, alt, speed, dir}` for an entry of `ride.others` moved on by its speed and dir, round the end of the lap (see 10.5). `onRiders` delivers `{id, nick, lat, lon, speed, app, distance, dir, routeId}`; recognise riders by `id`. The SDK has no code that touches a trainer; `ride.update` only works out speed and distance and sends `distance`, `dir` and `speed`. Without the SDK you do the same in 9.5: the measured power into `speed()`, the result into the presence message, and keep your ERG control separate. ### Rider ids Every entry of `riders` has an `id`: an opaque string like "r_3f9a1c07b2de". It is NOT an account id and cannot be turned back into one. It is the same for the same rider on every connection and in every app for the rest of that UTC day (it changes after midnight UTC and when the server restarts), so you can follow a rider between frames and interpolate their position. Recognise riders by id, never by nick: nicknames are chosen by the riders and are NOT unique. Do not store ids; they are only for the live list. ### Smooth other riders (example) Smoothing other riders (about 20 lines; the same recipe as in the web app). Updates come about once a second, so draw in every animation frame from the last frame per `id`, never straight from the list: ```js const seen = new Map(); // id -> { frame, at, d } (key on id, never on nick) ride.onRiders((riders) => { const at = performance.now(); for (const r of riders) seen.set(r.id, { ...seen.get(r.id), frame: r, at }); }); const L = ride.lengthM, wrap = (a, b) => (((b - a + L / 2) % L) + L) % L - L / 2; // shortest way round the lap let prev = performance.now(); function frame(now) { // requestAnimationFrame const dt = (now - prev) / 1000; prev = now; for (const [id, s] of seen) { const age = now - s.at; // ms since this rider's last update if (age > 6000) { seen.delete(id); hide(id); continue; } // nothing for 6 s: gone const x = Math.max(0, age - 3000); // after 3 s brake to a stop (linear) and fade out const p = ride.predict(s.frame, Math.min(age, 3000) + x - (x * x) / 6000); // frame + speed * dt, round the lap if (s.d == null || Math.abs(wrap(s.d, p.distance)) > 25) s.d = p.distance; // first frame or teleport: no glide else s.d = (s.d + wrap(s.d, p.distance) * (1 - Math.exp(-dt * 7)) + L) % L; // correct softly (about 0.15 s) draw(id, ride.pointAt(s.d), Math.min(1, 1 - x / 3000)); // position and opacity } requestAnimationFrame(frame); } requestAnimationFrame(frame); ``` ### Rendering with three.js To show the shared world in 3D use three.js (pinned: 0.170.0, from a CDN through an importmap, no build step). Draw the road from `ride.geojson` (lon, lat, altitude per point), put your rider at `ride.position` (from `pointAt(distance)`), draw the others from `onRiders` (always lat/lon; `distance` only on shared road) and smooth them with `ride.predict`. Needs `ride` from 9.6. ```html ``` ```js import * as THREE from 'three'; const co = ride.geojson.geometry.coordinates, [lon0, lat0] = co[0]; // [lon, lat, altitude m] const xyz = ({ lon, lat, alt = 0 }) => new THREE.Vector3((lon - lon0) * 111320 * Math.cos(lat0 * Math.PI / 180), alt, -(lat - lat0) * 110540); // metres, y up, -z north const scene = new THREE.Scene(), cam = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 1, 30000); const gl = new THREE.WebGLRenderer({ antialias: true }); gl.setSize(innerWidth, innerHeight); document.body.append(gl.domElement); scene.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(co.map(([lon, lat, alt]) => xyz({ lon, lat, alt }))), new THREE.LineBasicMaterial({ color: 0x888888 }))); const dot = (color) => { const m = new THREE.Mesh(new THREE.SphereGeometry(6), new THREE.MeshBasicMaterial({ color })); scene.add(m); return m; }; const me = dot(0xff5500), meshes = new Map(), seen = new Map(); // id -> mesh / { frame, at } ride.onRiders((riders) => { for (const r of riders) seen.set(r.id, { frame: r, at: performance.now() }); }); gl.setAnimationLoop((now) => { me.position.copy(xyz(ride.position)); // my rider: pointAt(distance), lat/lon/alt cam.position.copy(me.position).add(new THREE.Vector3(0, 250, 400)); cam.lookAt(me.position); for (const [id, s] of seen) { if (now - s.at > 6000) { seen.delete(id); meshes.get(id)?.removeFromParent(); meshes.delete(id); continue; } if (!meshes.has(id)) meshes.set(id, dot(0x2288ff)); const p = ride.predict(s.frame, now - s.at); // on shared road: moved on by speed and dir; off route: just lat/lon meshes.get(id).position.copy(xyz(p.alt == null ? { ...p, alt: me.position.y } : p)); } gl.render(scene, cam); }); ``` Minimal apps can skip 3D: draw `ride.geojson` as an SVG polyline (or canvas) plus an elevation profile from `?profile=1` (9.2), and move one dot per rider. Keep showing the attribution (`ride.attribution`). ## 10. Building a simulator or virtual world Ravito only knows virtual positions: a place on a shared route and a distance along it. There is no free lat/lon world and no "riders near you", except open presence for riders who opted in (section 10b). 1. Ride a shared route: `place` = an `id` from GET /api/v1/shared-routes (now "mallorca-west") and `distance` in metres along it (0 to lengthM). Your simulator may be a different world; you map your rider onto this route line, for example by playing the profile (section 9.2) as your course. 2. lat/lon only if the rider really is on the line: with `lat`/`lon` instead of `distance` the server snaps you to the route only when you are within 30 m of it; further away is `off_route`. A simulator should send `distance`. 3. Speed and direction: send `speed` (m/s, up to 33, so a fast simulated descent is fine) and `dir` (1 or -1). Everything is checked for plausibility against the previous report of the same connection; a refused report is always answered with an error (code, message, hint), never dropped. 4. Others see you with your app name; you see the visible riders of all apps on the same route. Ravito knows only virtual positions on shared routes: there is no visibility between a virtual rider and a real rider outdoors, unless that outdoor rider's app also reports a place on the shared route. 5. Smooth animation: report about once a second and keep the last two frames per `id`. Between frames, move a rider along the route by `distance + dir * speed * dt` (dt in seconds since their last frame), and correct softly when a new frame arrives. With the SDK: `ride.predict(rider, performance.now() - receivedAt)` does the moving for you (lat, lon and distance, round the lap), and `ride.pointAt(distance)` gives any point of the line. The list is at most 30 riders, nearest first. 6. Rider visibility is the rider's own choice (section 3); a hidden rider sees nobody. Use the sandbox (section 6) for testing: it shows three bots and nobody real. No heart rate, power or target power of anyone is ever in a message. If your simulator controls a trainer, section 7 applies to you. ## 10b. Open presence and the world map (opt-in) Outside the shared routes a rider can be seen only when he chose to. "Always share my position" is a Ravito setting of the rider (on /platform/samen.html; off by default, only while visible). Your app cannot turn it on. 1. Send `{"type":"presence","mode":"open","lat":52.0907,"lon":5.1214,"speed":8.1}` about once a second (no place; lat/lon in degrees WGS84, speed 0 to 33 m/s). Positions are virtual: any route, GPX or world. 2. Reply: `{"type":"riders","mode":"open","riders":[{"nick","lat","lon","speed","app"}]}` with only other open riders within 500 m in a straight line, at most 30, nearest first, exactly those five fields. Mutual: a rider without the setting gets `{"type":"error","code":"not_sharing","message","hint"}`, sees nobody and is seen by nobody. 3. Home zone: the first open position of a connection is kept; while the rider stays within 300 m of it he is in nobody's list and not on the world map, and the reply is an empty list with `"holding": true`. 4. No scanning: there is no call for an area or for someone else's position. The jump rule and the limits of section 3 hold (shared with route presence). Route presence and open presence are separate rooms; an app that knows its rider is on a shared route uses route presence. One connection may switch; the last message wins. 5. World map: GET /api/v1/world-map (anonymous, cached 10 s) returns `{"updated","cells":[{"cell":"52.125,5.125","count":2}],"attribution"}`: per 0.25 by 0.25 degree cell (about 25 km) its centre and the number of sharing riders of the last 30 s outside their home zone; at most 2000 cells; never sandbox riders. No nick, speed, app or exact position, for nobody. Nothing is stored. The page is /world. 6. Privacy for app builders: ask the rider in your app before you send open presence, and say what it means: riders within 500 m see his nickname and position, his region shows on the world map, and a GPX that starts at home can give away the neighbourhood (the first 300 m are never shared). Turning the setting off removes him from every list and from the map at once. With the browser SDK: `await ravito.connectPresence(); ravito.shareOpen({ lat, lon, speed })` about once a second; `onRiders((riders, mode) => ...)` gets mode "open".