# Ravito 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: m, m/s, W, bpm, rpm, s, degrees WGS84. >Ravito is an open network for cycling apps. Riders from different apps see each other (name, position, speed) and keep their rides and profile in one place. Your app registers itself, the rider signs in and gives consent, and then it can share presence and upload rides. No account for you, no API key, no review. The server is the origin you fetched this file from (for example https://ravito.online). Everything is JSON over HTTPS and WSS. CORS is open: a browser app can call every API endpoint directly (Expose-Headers: Retry-After, ETag). Versions are in the path (/api/v1); changes inside v1 only add things. Full guide with code: /platform/llms-full.txt. Machine-readable API: /openapi/v1.json. Browser SDK (ES modules, no build): https://ravito.online/platform/sdk/v1/ravito.js (v1: later versions only add things that break nothing; a breaking change would be v2 on its own path). ## Steps 1. Register your app once: POST /connect/register with {"client_name","redirect_uris":["https://yourapp.example/cb"],"scope":"presence profile:read rides:write-own"}. You get a client_id. No secret: your app is a public PKCE client. A new app is "unverified" (riders are told on the consent screen) and may only ask for profile:read, presence, rides:write-own and offline_access. Redirect URIs (RFC 8252): https://..., http://127.0.0.1/cb or http://[::1]/cb (any port at sign-in; path must match exactly), or a private-use scheme such as com.example.app:/callback (single slash, exact). Desktop apps and games: use those. Names containing "Ravito" or "Hollowride" are reserved. Change name or redirects, and request "verified" (administrator decides; 10 instead of 2 presence messages per second), on /platform/ontwikkelaars.html. 2. Sign the rider in: OAuth 2.1 authorization code with PKCE S256 (discovery: /.well-known/openid-configuration). Easiest: the SDK, `await ravito.login()` and `await ravito.finishLogin()`. Access token 10 minutes, refresh token 30 days (rotating): full-guide section 2 has the curl refresh. 3. Presence: open the WebSocket /api/v1/ws with the access token (the subprotocol "ravito.bearer." works everywhere, including the global WebSocket of Node and browsers; the Authorization header only works with a library that can set headers, such as the npm package ws). The token is checked only when the socket opens: an open socket keeps working after the 10-minute access token expires and closes only on revoke, logout or 1008; to reconnect later, refresh first (offline_access, llms-full.txt section 6b), then send about once per second {"type":"presence","place":"mallorca-west","distance":1234.5,"dir":1,"speed":8.1}. Presence exists ONLY on a shared route (place = its id, see "Ride the shared route" below): there is no free lat/lon presence anywhere else and no "riders near you". The reply is {"type":"riders","riders":[{"id","nick","lat","lon","speed","app","distance","dir"}]}: the riders who made themselves visible and ride the same route. Nothing else is ever sent about another rider. Riders arrive ONLY as the reply to your own presence message: keep sending about once a second also while paused (speed 0), or the list stops updating. Riders are visible by default under a nickname (their own, or an automatic one like "Rider 4821"; never the username); a rider who turned visibility off at /platform/samen.html gets "mode":"hidden" and a hint, so test with the sandbox (step 6). No API sets visibility: GET /api/v1/shared/settings only reads it (profile:read); the rider changes it in the browser. 4. (Points of rides older than 24 months are removed every night.) Upload a ride: POST /api/v1/history/rides with {"start":"2026-11-07T09:00:00Z","points":[[t_seconds,lat,lon,alt?,watt?,hr?,cadence?,speed?],...] (t_seconds relative to start; everything after lon may be left out or null; units: s, degrees WGS84, m, W, bpm, rpm, m/s)} (max 5 MB, 12 hours). Add ?dry_run=1 to only validate. It shows up in the rider's ride list with your app's name. It never counts for a segment time, PR, KOM or ranking. 5. Errors always look like {"code","message","hint"}; read `hint`, it says what to do. Codes are stable: invalid_client_metadata, client_name_taken (409, name in use), client_name_reserved (400), invalid_redirect_uri, scope_not_allowed, registration_rate_limited, too_many_clients, invalid_presence, implausible, rate_limited, too_many_connections, invalid_ride, ride_too_large, duplicate_ride, ride_quota, upload_budget, unknown_client, config_client. The message text is Dutch today; use code and hint. 6. Test without touching real riders: use the client_id `ravito-sandbox` (scopes profile:read, presence, rides:write-own; redirect URIs only http://localhost:/ or /callback on ports 3000, 3001, 4000, 4173, 4200, 5000, 5173, 5174, 5500, 8000, 8080, 8081, 8888, 9000, also on 127.0.0.1). A page opened as file:// has no valid redirect URI: serve it from a local server on one of those ports, for example `npx serve -l 5173` or `python -m http.server 8000`. You see three bot riders; real riders never see you; uploads are validated and not stored. Consent is as for any app: the first sign-in of a rider shows the consent screen, a later one with the same scopes redirects straight back with the code (a script must not wait for the allow button). A second rider is a second account on the sandbox: two accounts see each other as proefrijder-xxxx (the same account on two sockets does not see itself). 7. Profile: GET /api/v1/profile (profile:read, read only) answers {"profile":{"riderKg":78,"bikeKg":8.5,"totalKg":86.5,"ftp":250,"restingHr":55,"thresholdHr":168,"maxHr":185,"birthYear":1980 or null,...},"confirmed":true,"limits":{...},"zones":{"method":"Karvonen","hr":[...],"power":[...]}}. Defaults when the rider never filled it in: check "confirmed". Details: llms-full.txt section 2. 8. Limits: 10 registrations (60 attempts) per hour per IP; unverified apps 2 presence messages per second and 3 connections per rider (verified 10 per second). A presence position that does not follow plausibly from the previous one of the same connection (more than 33 m/s times the elapsed time plus 50 m; the first report of a connection is free) or a speed over 33 m/s (118.8 km/h) is ignored; five in a minute close the socket (code 1008). Revoking the app closes the socket. ## Ride the shared route (any language, no SDK needed) Apps of every kind can ride open routes together in one shared world, Mallorca: `mallorca-west` (Serra de Tramuntana, 51 266 m, 1 042 m climbing, start and end 12 m apart: ride it as a lap), `mallorca-soller-kort` (3 046 m), `mallorca-coll-de-soller` (27 581 m, out and back) and `mallorca-deia` (23 682 m, out and back). Your app picks one route id; riders on different routes see each other where the routes share the road. Ravito only gives the line, the profile and the other riders. YOUR app works out its own speed and distance. The Ravito server calculates nothing and sends nothing to a trainer; it only checks that a position is plausible. Human version with a diagram: /platform/docs.html#route. Everything below is also in /platform/llms-full.txt section 9 (with code). The browser SDK does all of it in two lines (`const ride = await ravito.joinRoute('mallorca-west'); ride.update({ power, mass })`), but nothing here depends on it. 1. Route (open data, no sign-in): GET /api/v1/routes/mallorca-west is a GeoJSON Feature (RFC 7946): geometry LineString with [lon, lat, altitude m] per point (every 5 m), properties.distances = cumulative metres of every point, properties.lengthM, ascentM, attribution, license. ?format=gpx gives GPX, ?profile=1 gives {"id","lengthM","ascentM","attribution","license","columns":["distanceM","altitudeM","gradePercent"],"stepM":50,"profile":[[0,33.7,-2.66],[50,33.7,3.04],...]} (grade in percent, positive uphill, every 50 m, ending at lengthM). GET /api/v1/shared-routes lists the routes: id, name, lengthM, ascentM, attribution, license, url, place, world ("mallorca", a label only) and riders (how many visible riders ride it now, at most 5 s old; show it in your route choice). Unknown id: 404 {"code":"unknown_route","message","hint"}; unknown format: 400 unknown_format. Data: line (c) OpenStreetMap contributors, ODbL (mallorca-west via BRouter); altitude (c) IGN/CNIG MDT05, CC BY 4.0: show the attribution of your route (it is in every answer) to your users. 2. Sign in and open the socket exactly as in steps 2 and 3 (scope presence), then send about once per second (at most 2 per second unverified): {"type":"presence","place":"mallorca-west","distance":1234.5,"dir":1,"speed":4.11}. distance = metres along the route from its start, 0 to lengthM (required in this form, else invalid_presence); dir = 1 (default) or -1 (riding the route the other way round), anything else is invalid_presence; speed = m/s, 0 to 30 (optional). The server works out lat and lon from the line. You may also send {"type":"presence","place":"mallorca-west","lat":39.76,"lon":2.71} (GPS): within 30 m of the line the server snaps you to a 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. A place that is not a shared route, or a missing place, is invalid_presence. 3. Reply to every valid message: {"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"}]}. Every rider has id (opaque, stable while that rider is connected and for the day: recognise riders by id, nicknames are not unique), nick, lat, lon, speed, app and routeId (the route that rider rides); ignore fields you do not know. Riders of all apps are in one list, and so are all visible riders of other routes of the same world. distance and dir are present only when that rider is on road your route shares (within 30 m, stretches of 50 m or more): then they are projected onto YOUR route (place them as usual); otherwise they are ABSENT (check before use, place him by lat/lon). lat/lon are always the rider's own position. To switch route send another place (SDK: joinRoute again) at the same spot on a shared road; the jump rule on the map holds across a switch. Only riders who made themselves visible; at most 30, nearest first; mode "hidden" (empty list, with a hint) when you yourself are not visible. Never heart rate, power or target power. 4. 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, 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 speed above 33 m/s, is ignored with {"type":"error","code":"implausible","message","hint"}; five in a minute close the socket (1008). So you cannot scan the route by jumping: move like a rider. Also rate_limited over 2 messages per second (unverified app), too_many_connections, invalid_presence, off_route. 5. Reference bike model (Martin et al. 1998), optional, your app may use another one: for power P (W), total mass m (kg, rider plus bike) and grade g (percent, atan(g/100) = a), speed v (m/s) is the solution of P = v * (m*9.81*sin(a) + m*9.81*0.0045*cos(a) + 0.5*1.225*0.32*v*v) (Crr 0.0045, CdA 0.32 m2, air density 1.225 kg/m3). Solve it by bisection on v between 0 and 40. Then every second: grade = profile grade at your distance (interpolate), v = model, distance = distance + dir*v*dt, wrap round at lengthM (and below 0). Check yourself: 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 %; 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). 6. For testing use the sandbox (step 6): your own presence replies also show three bot riders that ride the route with distance, lat and lon next to you. 7. Example, a workout app (ERG, intervals): the rider rides along virtually on mallorca-west during the workout. The app keeps controlling its own trainer in ERG by its workout plan; the real power the trainer measures goes through the bike model to speed and distance (step 5); Ravito only shows the position to the others and never sends anything back to the trainer. With the SDK, in your workout loop (once per second; `mass` = profile totalKg, 85 as fallback): `trainer.setErgTarget(targetWatts); ride.update({ power: measuredWatts, mass });` after `const ride = await ravito.joinRoute('mallorca-west');`. Without the SDK: measured power into the model, the result as `distance` in the presence message of step 2, and keep the ERG control separate. 8. Building a simulator or virtual world: positions are virtual; use place = a shared route id and distance, see /platform/llms-full.txt section 10. 9. SDK helpers (all optional): `ride.update({power, mass, dt?})` advances by `dt` seconds; without `dt` it uses the time since the last call, and a gap over 2 s (a pause) counts as 0 so the rider never jumps. `ride.pointAt(distance)` gives {lat, lon, alt, grade} and `ride.geojson` is the route line, so you need no second fetch. `ride.predict(rider, msSinceUpdate)` moves another rider (an entry of `ride.others`) on by its speed and dir, round the lap, for smooth animation. `onRiders` delivers {id, nick, lat, lon, speed, app, distance, dir, routeId}: recognise riders by `id`. Take `mass` from GET /api/v1/profile (`totalKg`, or `riderKg` + `bikeKg`), 85 only as a fallback. 10. 3D view: use three.js (three@0.170.0 from a CDN through an importmap); working snippet in /platform/llms-full.txt "Rendering with three.js". Minimal apps: an SVG/canvas map of `ride.geojson` plus a profile. 11. Complete code in Node and Python: /platform/llms-full.txt section 9.5. Browser SDK: https://ravito.online/platform/sdk/v1/ravito.js. ## Open presence and the world map (opt-in) A rider who turned on "Always share my position" (Ravito settings, off by default; your app cannot turn it on) may report `{"type":"presence","mode":"open","lat":52.09,"lon":5.12,"speed":8}` anywhere (no place). The reply lists only other open riders within 500 m (exactly nick, lat, lon, speed, app; at most 30, nearest first); the first 300 m of a connection are never shared (empty list, "holding": true). Without the setting: error not_sharing. Same jump rule and limits as route presence; route and open presence do not see each other. Ask the rider for consent in your app first. GET /api/v1/world-map (anonymous) gives only counts per 0.25 degree cell. Details: /platform/llms-full.txt section 10b. ## Differentiate, but ride together Apps should differ in style and purpose (workout app, racing game, coaching, casual, 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 (the Mallorca routes from GET /api/v1/shared-routes) during workouts, free rides and races, so the shared map gets busy and every rider sees riders from other apps. When your rider starts a workout or a free ride, offer "ride it on the shared route" and join presence (steps above); show the other riders your own way. ## Accounts: your own account and the rider's Ravito account Recommended: "Sign in with Ravito" (OAuth PKCE, step 2), either as your only login or as a link on an existing app account. When linking: store the rider's stable id (`id` from GET /api/v1/account/me, scope profile:read), never the name; one Ravito account per app account; offer unlink (POST /connect/revoke with the refresh token) and treat invalid_grant on a refresh as unlinked; never ask for the Ravito password; request only the scopes you need. Details and pseudocode: /platform/llms-full.txt section 8c. ## 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, and it never shares heart rate, power or target power of one rider with another. If your app controls a smart trainer, YOUR app is responsible for doing that safely. Recommended practice: never a high target power without a valid heart rate; heart-rate loss, maximum heart rate, pause and stop all mean minimum power; a simulated heart rate never drives a real trainer; the position or presence of other riders never decides a target power. ## More - Protocol specification (implementation neutral): docs/ravito-protocol.md (the open protocol specification). - Rider data stays with the rider: apps only see what the rider consented to; riders can revoke an app on their account page.