REST · JSON · OAuth 2.0 v1.24 All systems operational · 99.98%

The StrideX API

Every activity, route, segment, challenge and health metric your users have — behind one athlete-authorised token. 28 endpoints, six webhook events, five official SDKs, and rate limits that let you build something real.

BASE URL https://api.stridex.app/v1
Read the docs
28endpoints
84msp50 latency
4.2Mcalls / day
99.98%90-day uptime
GET /v1/athlete/activities 200 OK · 84ms
$ curl "https://api.stridex.app/v1/athlete/activities?per_page=1" \
    -H "Authorization: Bearer $STRIDEX_ACCESS_TOKEN"

[
  {
    "id": "act1",
    "sport": "run",
    "title": "Hulhumalé sunrise long run 🌅",
    "distance_km": 32.4,
    "moving_time": 8712,
    "average_hr": 152,
    "average_power": 288,
    "start_date": "2026-07-14T00:42:00Z",
    "prs": ["Fastest Half Marathon", "Longest Run"],
    "device": "Garmin Forerunner 965"
  }
]
Quickstart

Your first activity in three steps

1

Authenticate with OAuth 2.0

Send the athlete to the authorize screen, then exchange the code you get back for an access token. Tokens are athlete-scoped — you never handle a password.

# 1. Send the athlete here (in a browser, not curl)
open "https://api.stridex.app/v1/oauth/authorize\
?client_id=YOUR_CLIENT_ID\
&redirect_uri=https://yourapp.com/callback\
&response_type=code\
&scope=read,activity:read_all\
&state=x7fa29c"

# 2. Exchange the ?code= from the redirect for tokens
curl -X POST https://api.stridex.app/v1/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "YOUR_CLIENT_ID",
    "client_secret": "YOUR_CLIENT_SECRET",
    "code": "AUTH_CODE_FROM_REDIRECT",
    "grant_type": "authorization_code"
  }'
// 1. Send the athlete to the authorize screen
const url = new URL('https://api.stridex.app/v1/oauth/authorize');
url.search = new URLSearchParams({
  client_id: process.env.STRIDEX_CLIENT_ID,
  redirect_uri: 'https://yourapp.com/callback',
  response_type: 'code',
  scope: 'read,activity:read_all',
  state: crypto.randomUUID(),          // verify this on the way back
});
window.location.href = url.toString();

// 2. On your callback route, exchange the code for tokens
const res = await fetch('https://api.stridex.app/v1/oauth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    client_id: process.env.STRIDEX_CLIENT_ID,
    client_secret: process.env.STRIDEX_CLIENT_SECRET,
    code,
    grant_type: 'authorization_code',
  }),
});

const { access_token, refresh_token, expires_at } = await res.json();
import os, secrets, requests

# 1. Send the athlete to the authorize screen
authorize_url = (
    "https://api.stridex.app/v1/oauth/authorize"
    f"?client_id={os.environ['STRIDEX_CLIENT_ID']}"
    "&redirect_uri=https://yourapp.com/callback"
    "&response_type=code"
    "&scope=read,activity:read_all"
    f"&state={secrets.token_hex(4)}"
)

# 2. Exchange the ?code= from the redirect for tokens
res = requests.post(
    "https://api.stridex.app/v1/oauth/token",
    json={
        "client_id": os.environ["STRIDEX_CLIENT_ID"],
        "client_secret": os.environ["STRIDEX_CLIENT_SECRET"],
        "code": code,
        "grant_type": "authorization_code",
    },
    timeout=10,
)
res.raise_for_status()
tokens = res.json()
import Foundation
import AuthenticationServices

// 1. Send the athlete to the authorize screen
var comps = URLComponents(string: "https://api.stridex.app/v1/oauth/authorize")!
comps.queryItems = [
    URLQueryItem(name: "client_id", value: clientID),
    URLQueryItem(name: "redirect_uri", value: "stridexdemo://callback"),
    URLQueryItem(name: "response_type", value: "code"),
    URLQueryItem(name: "scope", value: "read,activity:read_all"),
]

let session = ASWebAuthenticationSession(
    url: comps.url!, callbackURLScheme: "stridexdemo"
) { callbackURL, error in
    guard let code = callbackURL?.queryValue("code") else { return }
    Task { try await exchange(code: code) }
}
session.start()

// 2. Exchange the code for tokens
struct Tokens: Decodable {
    let accessToken: String
    let refreshToken: String
    let expiresAt: Int
}

func exchange(code: String) async throws {
    var req = URLRequest(url: URL(string: "https://api.stridex.app/v1/oauth/token")!)
    req.httpMethod = "POST"
    req.setValue("application/json", forHTTPHeaderField: "Content-Type")
    req.httpBody = try JSONEncoder().encode([
        "client_id": clientID,
        "client_secret": clientSecret,
        "code": code,
        "grant_type": "authorization_code",
    ])

    let (data, _) = try await URLSession.shared.data(for: req)
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    Keychain.store(try decoder.decode(Tokens.self, from: data))
}
import android.content.Intent
import android.net.Uri
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import kotlinx.serialization.json.*

// 1. Send the athlete to the authorize screen
val authorize = Uri.parse("https://api.stridex.app/v1/oauth/authorize")
    .buildUpon()
    .appendQueryParameter("client_id", BuildConfig.STRIDEX_CLIENT_ID)
    .appendQueryParameter("redirect_uri", "stridexdemo://callback")
    .appendQueryParameter("response_type", "code")
    .appendQueryParameter("scope", "read,activity:read_all")
    .build()

startActivity(Intent(Intent.ACTION_VIEW, authorize))

// 2. Exchange the code for tokens (on your callback activity)
val payload = buildJsonObject {
    put("client_id", BuildConfig.STRIDEX_CLIENT_ID)
    put("client_secret", BuildConfig.STRIDEX_CLIENT_SECRET)
    put("code", code)
    put("grant_type", "authorization_code")
}.toString().toRequestBody("application/json".toMediaType())

val request = Request.Builder()
    .url("https://api.stridex.app/v1/oauth/token")
    .post(payload)
    .build()

val tokens = client.newCall(request).execute().use { res ->
    Json.decodeFromString<Tokens>(res.body!!.string())
}
2

Fetch the athlete’s activities

One authenticated GET. Results are newest-first and paginated at 30 per page (200 max). Always read the rate-limit headers — they are on every response.

curl "https://api.stridex.app/v1/athlete/activities?per_page=10&page=1" \
  -H "Authorization: Bearer $STRIDEX_ACCESS_TOKEN"

# Response headers worth reading:
#   X-RateLimit-Limit:     600
#   X-RateLimit-Remaining: 594
#   X-RateLimit-Reset:     1784102400
const res = await fetch(
  'https://api.stridex.app/v1/athlete/activities?per_page=10&page=1',
  { headers: { Authorization: `Bearer ${accessToken}` } },
);

if (res.status === 429) {
  const retry = Number(res.headers.get('Retry-After') ?? 60);
  throw new RateLimited(retry);        // back off, do not hammer
}
if (!res.ok) throw new StrideXError(await res.json());

const activities = await res.json();
console.log(res.headers.get('X-RateLimit-Remaining'));  // 594
res = requests.get(
    "https://api.stridex.app/v1/athlete/activities",
    headers={"Authorization": f"Bearer {access_token}"},
    params={"per_page": 10, "page": 1},
    timeout=10,
)

if res.status_code == 429:
    raise RateLimited(int(res.headers.get("Retry-After", 60)))

res.raise_for_status()
activities = res.json()
print(res.headers["X-RateLimit-Remaining"])  # 594
var req = URLRequest(url: URL(string:
    "https://api.stridex.app/v1/athlete/activities?per_page=10&page=1")!)
req.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")

let (data, response) = try await URLSession.shared.data(for: req)
guard let http = response as? HTTPURLResponse else { throw StrideXError.transport }

if http.statusCode == 429 {
    let retry = Int(http.value(forHTTPHeaderField: "Retry-After") ?? "60")!
    throw StrideXError.rateLimited(retryAfter: retry)
}

let activities = try decoder.decode([Activity].self, from: data)
val request = Request.Builder()
    .url("https://api.stridex.app/v1/athlete/activities?per_page=10&page=1")
    .header("Authorization", "Bearer $accessToken")
    .build()

val activities: List<Activity> = client.newCall(request).execute().use { res ->
    if (res.code == 429) {
        throw RateLimited(res.header("Retry-After")?.toInt() ?: 60)
    }
    if (!res.isSuccessful) throw StrideXError(res.code, res.body?.string())

    json.decodeFromString(res.body!!.string())
}
3

Read the response

Distances are kilometres, times are seconds, paces are yours to calculate. Every activity carries the same shape whatever the sport — GPS fields are simply null for indoor ones.

curl -s "https://api.stridex.app/v1/athlete/activities?per_page=3" \
  -H "Authorization: Bearer $STRIDEX_ACCESS_TOKEN" \
| jq -r '.[] | "\(.sport) · \(.distance_km) km · \(.average_hr) bpm"'

# run  · 32.4 km · 152 bpm
# swim · 3.8 km  · 141 bpm
# ride · 28.2 km · 118 bpm
for (const act of activities) {
  const secPerKm = act.moving_time / act.distance_km;
  const pace = `${Math.floor(secPerKm / 60)}:${String(Math.round(secPerKm % 60)).padStart(2, '0')}`;

  console.log(
    `${act.sport} · ${act.distance_km.toFixed(2)} km · ${pace}/km · ${act.average_hr} bpm`,
  );
}

// run · 32.40 km · 4:29/km · 152 bpm
// swim · 3.80 km · 17:22/km · 141 bpm
for act in activities:
    sec_per_km = act["moving_time"] / act["distance_km"]

    print(
        f"{act['sport']} · {act['distance_km']:.2f} km "
        f"· {int(sec_per_km // 60)}:{round(sec_per_km % 60):02d}/km "
        f"· {act['average_hr']} bpm"
    )

# run · 32.40 km · 4:29/km · 152 bpm
# swim · 3.80 km · 17:22/km · 141 bpm
for act in activities {
    let secPerKm = Double(act.movingTime) / act.distanceKm
    let pace = String(format: "%d:%02d", Int(secPerKm) / 60, Int(secPerKm) % 60)

    print("\(act.sport) · \(String(format: "%.2f", act.distanceKm)) km · \(pace)/km · \(act.averageHr) bpm")
}

// run · 32.40 km · 4:29/km · 152 bpm
// swim · 3.80 km · 17:22/km · 141 bpm
activities.forEach { act ->
    val secPerKm = act.movingTime / act.distanceKm
    val pace = "%d:%02d".format((secPerKm / 60).toInt(), (secPerKm % 60).roundToInt())

    println("${act.sport} · %.2f km · $pace/km · ${act.averageHr} bpm".format(act.distanceKm))
}

// run · 32.40 km · 4:29/km · 152 bpm
// swim · 3.80 km · 17:22/km · 141 bpm
Authentication

OAuth 2.0, authorization code flow

Every call is made on behalf of an athlete who explicitly granted your app a set of scopes. There are no API-key-only endpoints for athlete data, no password grant, and no way to widen a scope after the fact — you re-prompt, or you go without.

Scopes

Ask for the least you can live with
ScopeGrantsDefault
read Public profile, public activities, clubs and challenges the athlete has joined. Implicit
activity:read_all Every activity including followers-only and private ones, with full streams. Opt-in
activity:write Create, update and delete activities on the athlete’s behalf. Uploads count against their history. Opt-in
profile:read Email, weight, FTP, HR zones, sport preferences and device list. Opt-in
profile:write Update profile fields, zones and training preferences. Opt-in
health:read Sleep, HRV, resting HR, recovery and body-composition metrics. Sensitive — expect scrutiny at review. Opt-in
coach:manage Read and write plans for athletes who have accepted you as their coach. Coach plan only. Opt-in

Athletes see this table almost verbatim on the consent screen. Apps that request health:read or activity:write without a visible reason get refused at roughly triple the rate — ask for them at the moment the feature needs them, not at sign-up.

Token request

POST /oauth/token
POST /v1/oauth/token HTTP/1.1
Host: api.stridex.app
Content-Type: application/json

{
  "client_id": "84210",
  "client_secret": "8f2c4a106b7d4e219c33a41f9c2e88b0",
  "code": "1f5a8e2b7d40c8834b19e5f26a4c19e5",
  "grant_type": "authorization_code"
}

Token response

200 OK
{
  "token_type": "Bearer",
  "access_token": "sxa_9c33a41f9c2e88b07d643ac15e920f77",
  "refresh_token": "sxr_b8d36a4c19e58f2c4a106b7d4e219c33",
  "expires_at": 1784124000,
  "expires_in": 21600,
  "scope": "read,activity:read_all",
  "athlete": {
    "id": "me",
    "handle": "@aiman.runs",
    "name": "Aiman Jaleel",
    "city": "Malé",
    "country": "Maldives",
    "premium": true
  }
}

Refreshing

Access tokens live 6 hours
curl -X POST https://api.stridex.app/v1/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "84210",
    "client_secret": "YOUR_CLIENT_SECRET",
    "refresh_token": "sxr_b8d36a4c19e58f2c4a106b7d4e219c33",
    "grant_type": "refresh_token"
  }'

Refresh tokens do not expire, but they are single-use: every refresh returns a new one and invalidates the old. Store the new one before you use the access token, or a crash mid-flight logs the athlete out.

Reference

28 endpoints, 10 resources

🏃

Athletes

4 endpoints
GET /athlete The authenticated athlete.
Request
curl https://api.stridex.app/v1/athlete -H "Authorization: Bearer $TOKEN"
Response 200
{
  "id": "me",
  "name": "Aiman Jaleel",
  "handle": "@aiman.runs",
  "initials": "AJ",
  "city": "Malé",
  "country": "Maldives",
  "flag": "🇲🇻",
  "verified": true,
  "badge": null,
  "followers": 1284,
  "following": 342,
  "ytd_km": 1043.6,
  "level": 27,
  "xp": 68420,
  "streak_days": 34,
  "bio": "Chasing a sub-3 marathon from a country with no hills.",
  "sports": ["run", "ride", "swim"],
  "premium": true,
  "created_at": "2024-01-12T04:18:00Z"
}
GET /athletes/{id} Any athlete by id. Respects their privacy settings.
Parameters
NameTypeDescription
idstringAthlete id, e.g. a1. Required.
include_statsbooleanAttach the YTD/all-time stats block. Default false.
Request
curl https://api.stridex.app/v1/athletes/a1?include_stats=true -H "Authorization: Bearer $TOKEN"
Response 200
{
  "id": "a1",
  "name": "Sara Nasheed",
  "handle": "@sara.tri",
  "city": "Hulhumalé",
  "country": "Maldives",
  "verified": true,
  "badge": "pro",
  "followers": 24800,
  "ytd_km": 2310.4,
  "sports": ["swim", "ride", "run"],
  "stats": {
    "ytd_activity_count": 412,
    "ytd_moving_time": 1284600,
    "all_time_km": 9840.2
  }
}
PUT /athlete Update the authenticated athlete. Needs profile:write.
Parameters
NameTypeDescription
biostringMax 240 characters.
citystringFree text.
weight_kgnumberUsed for calorie and power modelling.
ftpnumberFunctional threshold power in watts.
Request
curl -X PUT https://api.stridex.app/v1/athlete -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -d '{"weight_kg": 68.4, "ftp": 268}'
Response 200
{
  "id": "me",
  "weight_kg": 68.4,
  "ftp": 268,
  "updated_at": "2026-07-14T13:42:11Z"
}
GET /athlete/stats Rolled-up totals by sport and period.
Parameters
NameTypeDescription
periodstringweek | month | ytd | all. Default ytd.
Request
curl "https://api.stridex.app/v1/athlete/stats?period=ytd" -H "Authorization: Bearer $TOKEN"
Response 200
{
  "period": "ytd",
  "totals": {
    "run":  { "count": 186, "distance_km": 1043.6, "moving_time": 402840, "elevation_gain": 2140 },
    "ride": { "count": 42,  "distance_km": 1284.2, "moving_time": 168600, "elevation_gain": 640 },
    "swim": { "count": 61,  "distance_km": 148.4,  "moving_time": 186420, "elevation_gain": 0 }
  },
  "biggest_run_km": 32.4,
  "longest_streak_days": 96
}

Activities

6 endpoints
GET /athlete/activities The athlete’s activities, newest first.
Parameters
NameTypeDescription
pageintegerPage number. Default 1.
per_pageintegerDefault 30, max 200.
beforeintegerUnix seconds — activities started before this.
afterintegerUnix seconds — activities started after this.
sportstringFilter to one sport key, e.g. run.
Request
curl "https://api.stridex.app/v1/athlete/activities?per_page=2&sport=run" -H "Authorization: Bearer $TOKEN"
Response 200
[
  {
    "id": "act1",
    "athlete_id": "me",
    "sport": "run",
    "title": "Hulhumalé sunrise long run 🌅",
    "description": "Legs felt springy the whole way. Held marathon pace to 24K.",
    "distance_km": 32.4,
    "moving_time": 8712,
    "elapsed_time": 8964,
    "elevation_gain": 86,
    "calories": 2140,
    "average_hr": 152,
    "max_hr": 178,
    "average_cadence": 176,
    "average_power": 288,
    "stride_length": 1.21,
    "temperature": 29,
    "weather": "Clear",
    "wind": "9 km/h NE",
    "humidity": 78,
    "start_date": "2026-07-14T00:42:00Z",
    "start_latlng": [4.2105, 73.5405],
    "is_loop": false,
    "kudos_count": 148,
    "comment_count": 22,
    "photo_count": 3,
    "prs": ["Fastest Half Marathon", "Longest Run"],
    "achievements": ["Marathon-ready", "30K Club"],
    "perceived_exertion": 8,
    "device": "Garmin Forerunner 965",
    "visibility": "public",
    "was_live": true,
    "map": {
      "summary_polyline": "u{~vFvyys@fS]|@rDvBnG`@lAtCbG..."
    }
  },
  {
    "id": "act12",
    "athlete_id": "me",
    "sport": "run",
    "title": "Track night — 12×400m",
    "distance_km": 11.2,
    "moving_time": 3180,
    "elevation_gain": 4,
    "average_hr": 162,
    "max_hr": 184,
    "average_cadence": 186,
    "average_power": 322,
    "start_date": "2026-07-10T14:02:00Z",
    "kudos_count": 58,
    "prs": ["Fastest 1K"],
    "perceived_exertion": 9,
    "visibility": "public"
  }
]
GET /activities/{id} One activity in full, including splits.
Parameters
NameTypeDescription
idstringActivity id. Required.
include_splitsbooleanAttach per-km splits. Default true.
Request
curl https://api.stridex.app/v1/activities/act1 -H "Authorization: Bearer $TOKEN"
Response 200
{
  "id": "act1",
  "athlete_id": "me",
  "sport": "run",
  "title": "Hulhumalé sunrise long run 🌅",
  "distance_km": 32.4,
  "moving_time": 8712,
  "average_hr": 152,
  "splits": [
    { "km": 1, "moving_time": 268, "average_hr": 138, "elevation_gain": 2 },
    { "km": 2, "moving_time": 271, "average_hr": 144, "elevation_gain": 1 },
    { "km": 3, "moving_time": 269, "average_hr": 148, "elevation_gain": 4 }
  ],
  "device": "Garmin Forerunner 965",
  "visibility": "public"
}
POST /activities Create an activity. Needs activity:write.
Parameters
NameTypeDescription
sportstringOne of the 24 sport keys. Required.
titlestringRequired.
start_datestringISO 8601 UTC. Required.
distance_kmnumberRequired for GPS sports.
moving_timeintegerSeconds. Required.
filefileOptional GPX/TCX/FIT upload — everything else is then derived.
Request
curl -X POST https://api.stridex.app/v1/activities -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"sport":"run","title":"Easy 8K","start_date":"2026-07-14T00:40:00Z","distance_km":8.0,"moving_time":2880}'
Response 200
{
  "id": "act_9c33a41f",
  "athlete_id": "me",
  "sport": "run",
  "title": "Easy 8K",
  "distance_km": 8.0,
  "moving_time": 2880,
  "start_date": "2026-07-14T00:40:00Z",
  "visibility": "public",
  "created_at": "2026-07-14T02:11:04Z"
}
PUT /activities/{id} Update title, description, sport or visibility.
Parameters
NameTypeDescription
titlestring
descriptionstring
sportstringRe-derives metrics if the sport group changes.
visibilitystringpublic | followers | private
Request
curl -X PUT https://api.stridex.app/v1/activities/act1 -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -d '{"visibility": "followers"}'
Response 200
{
  "id": "act1",
  "visibility": "followers",
  "updated_at": "2026-07-14T13:44:52Z"
}
DELETE /activities/{id} Delete an activity. Irreversible, and it takes its PRs with it.
Request
curl -X DELETE https://api.stridex.app/v1/activities/act_9c33a41f -H "Authorization: Bearer $TOKEN"
Response 200
{
  "id": "act_9c33a41f",
  "deleted": true,
  "prs_revoked": 0
}
GET /activities/{id}/streams Raw time-series. The good stuff.
Parameters
NameTypeDescription
keysstringComma-separated: time, latlng, distance, altitude, heartrate, cadence, watts, temp, moving, grade.
resolutionstringlow (100) | medium (1000) | high (all). Default medium.
Request
curl "https://api.stridex.app/v1/activities/act1/streams?keys=heartrate,latlng,altitude&resolution=low" \
  -H "Authorization: Bearer $TOKEN"
Response 200
{
  "activity_id": "act1",
  "resolution": "low",
  "point_count": 100,
  "streams": {
    "time":      { "unit": "s",   "data": [0, 87, 174, 261, 348] },
    "heartrate": { "unit": "bpm", "data": [122, 138, 144, 149, 152] },
    "altitude":  { "unit": "m",   "data": [2.1, 2.4, 3.0, 2.8, 2.2] },
    "latlng":    { "unit": null,  "data": [[4.2105, 73.5405], [4.2118, 73.5412]] }
  }
}
🗺️

Routes

3 endpoints
GET /routes Search routes by area, sport and difficulty.
Parameters
NameTypeDescription
boundsstringsw_lat,sw_lng,ne_lat,ne_lng
sportstringSport key.
min_kmnumber
max_kmnumber
difficultystringEasy | Moderate | Hard | Expert
sortstringpopularity | rating | distance. Default popularity.
Request
curl "https://api.stridex.app/v1/routes?bounds=4.15,73.45,4.25,73.60&sport=run&sort=popularity" \
  -H "Authorization: Bearer $TOKEN"
Response 200
[
  {
    "id": "r1",
    "name": "Hulhumalé Beach Loop",
    "sport": "run",
    "distance_km": 8.4,
    "elevation_gain": 6,
    "city": "Hulhumalé",
    "country": "Maldives",
    "surface": "Paved + sand",
    "difficulty": "Easy",
    "rating": 4.8,
    "use_count": 18420,
    "estimated_time": 2760,
    "popularity": 96,
    "is_loop": true,
    "tags": ["Beach", "Flat", "Sunrise", "Water stops"],
    "offline_available": true
  }
]
GET /routes/{id} One route with its full polyline and elevation profile.
Request
curl https://api.stridex.app/v1/routes/r1 -H "Authorization: Bearer $TOKEN"
Response 200
{
  "id": "r1",
  "name": "Hulhumalé Beach Loop",
  "distance_km": 8.4,
  "description": "Flat, fast and forgiving — the island's default long-run loop.",
  "centre": [4.2105, 73.5405],
  "polyline": "u{~vFvyys@fS]|@rDvBnG`@lAtCbG...",
  "elevation_profile": [2.1, 2.4, 3.0, 2.8, 2.2, 1.9],
  "waypoints": [
    { "km": 0.0, "type": "start", "name": "Central Park gate" },
    { "km": 4.2, "type": "water", "name": "Phase 2 fountain" }
  ]
}
GET /routes/{id}/gpx Download as GPX. TCX and FIT also available.
Parameters
NameTypeDescription
formatstringgpx | tcx | fit. Default gpx.
Request
curl "https://api.stridex.app/v1/routes/r1/gpx" -H "Authorization: Bearer $TOKEN" -o beach-loop.gpx
Response 200
HTTP/1.1 200 OK
Content-Type: application/gpx+xml
Content-Disposition: attachment; filename="hulhumale-beach-loop.gpx"

(GPX 1.1 document, 842 track points)
🏁

Segments

3 endpoints
GET /segments/{id} Segment detail with the current KOM/QOM.
Request
curl https://api.stridex.app/v1/segments/s1 -H "Authorization: Bearer $TOKEN"
Response 200
{
  "id": "s1",
  "name": "Beach Road Sprint",
  "sport": "run",
  "distance_km": 1.0,
  "average_grade": 0.2,
  "city": "Hulhumalé",
  "kom": { "athlete_id": "a1", "name": "Sara Nasheed", "time": 178 },
  "attempt_count": 8420,
  "athlete_effort": { "best_time": 198, "rank": 42, "effort_count": 24 }
}
GET /segments/{id}/leaderboard Segment leaderboard, filterable.
Parameters
NameTypeDescription
scopestringglobal | country | club | friends. Default global.
age_groupstringe.g. 25_34
date_rangestringthis_year | this_month | today
per_pageintegerDefault 30.
Request
curl "https://api.stridex.app/v1/segments/s1/leaderboard?scope=country&per_page=3" -H "Authorization: Bearer $TOKEN"
Response 200
{
  "segment_id": "s1",
  "scope": "country",
  "entry_count": 1842,
  "entries": [
    { "rank": 1, "athlete_id": "a1", "name": "Sara Nasheed", "time": 178, "date": "2026-06-02" },
    { "rank": 2, "athlete_id": "x3", "name": "Ibrahim Waheed", "time": 184, "date": "2026-05-18" },
    { "rank": 3, "athlete_id": "x7", "name": "Fatima Ali", "time": 191, "date": "2026-07-01" }
  ],
  "athlete_entry": { "rank": 42, "time": 198, "date": "2026-07-10" }
}
GET /segments/explore Find segments inside a bounding box.
Parameters
NameTypeDescription
boundsstringRequired. sw_lat,sw_lng,ne_lat,ne_lng
sportstring
min_gradenumber
max_gradenumber
Request
curl "https://api.stridex.app/v1/segments/explore?bounds=4.15,73.45,4.25,73.60&sport=ride" \
  -H "Authorization: Bearer $TOKEN"
Response 200
{
  "segments": [
    { "id": "s2", "name": "Bridge Climb", "distance_km": 2.4, "average_grade": 3.1, "sport": "ride" },
    { "id": "s5", "name": "Airport Causeway TT", "distance_km": 6.8, "average_grade": 0.1, "sport": "ride" }
  ]
}
🎯

Challenges

2 endpoints
GET /challenges Challenges the athlete can see or has joined.
Parameters
NameTypeDescription
joinedbooleanOnly ones they joined.
typestringDaily | Weekly | Monthly | Corporate | School | Virtual …
Request
curl "https://api.stridex.app/v1/challenges?joined=true" -H "Authorization: Bearer $TOKEN"
Response 200
[
  {
    "id": "c1",
    "name": "July 200K Run",
    "sport": "run",
    "type": "Monthly",
    "goal": "Run 200 km in July",
    "target": 200,
    "unit": "km",
    "progress": 148.2,
    "joined": true,
    "participant_count": 284100,
    "ends_at": "2026-07-31T23:59:59Z",
    "days_left": 17,
    "sponsor": null
  }
]
POST /challenges/{id}/join Join a challenge as the authenticated athlete.
Parameters
NameTypeDescription
team_idstringFor team challenges. Optional.
Request
curl -X POST https://api.stridex.app/v1/challenges/c7/join -H "Authorization: Bearer $TOKEN"
Response 200
{
  "challenge_id": "c7",
  "joined": true,
  "progress": 0,
  "rank": 62401,
  "joined_at": "2026-07-14T13:48:20Z"
}
👥

Clubs

2 endpoints
GET /clubs/{id} Club profile and aggregate stats.
Request
curl https://api.stridex.app/v1/clubs/cl1 -H "Authorization: Bearer $TOKEN"
Response 200
{
  "id": "cl1",
  "name": "Malé Runners",
  "sport": "run",
  "city": "Malé",
  "country": "Maldives",
  "member_count": 2840,
  "verified": true,
  "private": false,
  "weekly_km": 8420,
  "event_count": 4,
  "athlete_role": "moderator"
}
GET /clubs/{id}/members Paginated member list. Moderators see pending requests.
Parameters
NameTypeDescription
pageinteger
per_pageintegerDefault 30, max 200.
statusstringactive | pending. Moderators only.
Request
curl "https://api.stridex.app/v1/clubs/cl1/members?per_page=2" -H "Authorization: Bearer $TOKEN"
Response 200
{
  "member_count": 2840,
  "page": 1,
  "members": [
    { "id": "me", "name": "Aiman Jaleel", "role": "moderator", "joined_at": "2021-03-04" },
    { "id": "a1", "name": "Sara Nasheed", "role": "member", "joined_at": "2019-01-22" }
  ]
}
🏅

Events

2 endpoints
GET /events Races and events, filterable by area and date.
Parameters
NameTypeDescription
countrystring
sportstring
fromstringISO date.
tostringISO date.
statusstringOpen | Closing soon | Sold out
Request
curl "https://api.stridex.app/v1/events?country=MV&sport=run" -H "Authorization: Bearer $TOKEN"
Response 200
[
  {
    "id": "e1",
    "name": "Malé Marathon 2026",
    "sport": "run",
    "date": "2026-08-08",
    "city": "Malé",
    "country": "Maldives",
    "distances": ["42.2K", "21.1K", "10K", "5K Fun Run"],
    "fee": "MVR 350",
    "spots": 5000,
    "taken": 4218,
    "status": "Closing soon",
    "registered": true,
    "bib": "MM-2416",
    "chip_timed": true,
    "certificate": true
  }
]
POST /events/{id}/register Register the athlete for an event.
Parameters
NameTypeDescription
distancestringMust be one of the event distances. Required.
estimated_finishintegerSeconds — used for corral seeding.
emergency_contactstringRequired by most organisers.
Request
curl -X POST https://api.stridex.app/v1/events/e3/register -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -d '{"distance": "120K", "estimated_finish": 14400}'
Response 200
{
  "event_id": "e3",
  "registered": true,
  "bib": "AGF-0413",
  "distance": "120K",
  "wave": "A",
  "payment": { "status": "pending", "amount": "MVR 500", "expires_at": "2026-07-15T13:50:00Z" }
}
❤️

Health

2 endpoints
GET /athlete/health Health metrics. Needs health:read — the most tightly reviewed scope we have.
Parameters
NameTypeDescription
metricsstringComma-separated: sleep, hrv, rhr, recovery, weight, body_fat, stress.
fromstringISO date.
tostringISO date.
Request
curl "https://api.stridex.app/v1/athlete/health?metrics=hrv,sleep,recovery&from=2026-07-12" \
  -H "Authorization: Bearer $TOKEN"
Response 200
{
  "athlete_id": "me",
  "metrics": {
    "hrv":      { "unit": "ms",    "baseline": 54, "series": [{ "date": "2026-07-13", "value": 48 }] },
    "sleep":    { "unit": "s",     "baseline": 27120, "series": [{ "date": "2026-07-13", "value": 22320, "deep": 3480, "rem": 6120, "wake_events": 3 }] },
    "recovery": { "unit": "score", "series": [{ "date": "2026-07-14", "value": 68 }] }
  },
  "note": "HRV 11% below baseline. Recovery is the limiter today."
}
POST /athlete/health Push metrics from your own device or scale.
Parameters
NameTypeDescription
datestringISO date. Required.
weight_kgnumber
body_fat_pctnumber
rhrinteger
hrvintegerms
Request
curl -X POST https://api.stridex.app/v1/athlete/health -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" -d '{"date":"2026-07-14","weight_kg":68.4,"rhr":52}'
Response 200
{
  "date": "2026-07-14",
  "accepted": ["weight_kg", "rhr"],
  "rejected": [],
  "source": "api:84210"
}
🏆

Leaderboards

1 endpoint
GET /leaderboards Ranked athletes by scope and metric.
Parameters
NameTypeDescription
scopestringglobal | country | city | friends | club. Default global.
club_idstringRequired when scope=club.
metricstringdistance | elevation | time | activities. Default distance.
periodstringweek | month | year. Default week.
sportstring
Request
curl "https://api.stridex.app/v1/leaderboards?scope=club&club_id=cl1&metric=distance&period=week" \
  -H "Authorization: Bearer $TOKEN"
Response 200
{
  "scope": "club",
  "club_id": "cl1",
  "metric": "distance",
  "period": "week",
  "unit": "km",
  "entries": [
    { "rank": 1, "athlete_id": "a1", "name": "Sara Nasheed", "value": 96.2, "delta": 2 },
    { "rank": 2, "athlete_id": "me", "name": "Aiman Jaleel", "value": 82.4, "delta": -1 },
    { "rank": 3, "athlete_id": "x1", "name": "Ibrahim Waheed", "value": 74.8, "delta": 3 }
  ],
  "athlete_rank": 2
}
🔔

Webhooks

3 endpoints
POST /webhooks Create a subscription. We verify it with a GET challenge first.
Parameters
NameTypeDescription
callback_urlstringHTTPS only. Required.
eventsarrayEvent types to receive. Required.
verify_tokenstringEchoed back in the verification GET.
Request
curl -X POST https://api.stridex.app/v1/webhooks -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"callback_url":"https://yourapp.com/hooks/stridex","events":["activity.created"],"verify_token":"x7fa29c"}'
Response 200
{
  "id": "wh_4a106b7d",
  "callback_url": "https://yourapp.com/hooks/stridex",
  "events": ["activity.created"],
  "status": "verifying",
  "created_at": "2026-07-14T13:52:00Z"
}
GET /webhooks List your subscriptions and their delivery health.
Request
curl https://api.stridex.app/v1/webhooks -H "Authorization: Bearer $TOKEN"
Response 200
[
  {
    "id": "wh_4a106b7d",
    "callback_url": "https://yourapp.com/hooks/stridex",
    "events": ["activity.created"],
    "status": "active",
    "last_delivery": { "at": "2026-07-14T13:50:12Z", "status_code": 200, "latency_ms": 84 },
    "success_rate_30d": 0.9998
  }
]
DELETE /webhooks/{id} Unsubscribe. Queued events are dropped immediately.
Request
curl -X DELETE https://api.stridex.app/v1/webhooks/wh_4a106b7d -H "Authorization: Bearer $TOKEN"
Response 200
{
  "id": "wh_4a106b7d",
  "deleted": true,
  "queued_events_dropped": 0
}
Webhooks

Stop polling. We'll tell you.

6 event types
EventFires when
activity.created An activity was recorded, uploaded or synced from a wearable. Most used
activity.updated Title, sport, description or visibility changed.
athlete.followed Someone followed the athlete, or they followed someone.
challenge.completed The athlete hit a challenge target. Fires once per challenge.
segment.pr A new personal record on a segment.
event.finished Chip time posted for a registered event. Includes splits. Timing

Sample delivery

activity.created
POST /hooks/stridex HTTP/1.1
Host: yourapp.com
Content-Type: application/json
X-StrideX-Signature: t=1784102400,v1=8f2c4a106b7d4e219c33a41f9c2e88b07d643ac15e920f77b8d36a4c19e5
X-StrideX-Delivery: dl_9c33a41f9c2e88b0
X-StrideX-Event: activity.created

{
  "event": "activity.created",
  "created_at": 1784102400,
  "athlete_id": "me",
  "object_id": "act1",
  "object_type": "activity",
  "subscription_id": "wh_4a106b7d",
  "data": {
    "id": "act1",
    "sport": "run",
    "title": "Hulhumalé sunrise long run 🌅",
    "distance_km": 32.4,
    "moving_time": 8712,
    "start_date": "2026-07-14T00:42:00Z"
  }
}

Verify the signature

Required
import crypto from 'node:crypto';

function verify(rawBody, header, secret) {
  const [tPart, v1Part] = header.split(',');
  const timestamp = tPart.split('=')[1];
  const signature = v1Part.split('=')[1];

  // Reject anything older than 5 minutes — stops replay attacks
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(timestamp + '.' + rawBody)
    .digest('hex');

  // Constant-time compare. Never use === here.
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

Signing · HMAC-SHA256

Every delivery carries X-StrideX-Signature in the form t=<unix>,v1=<hex>. The v1 value is an HMAC-SHA256 over timestamp + "." + rawBody, keyed with your endpoint's signing secret.

  • Sign the raw body — parse it after verifying, never before.
  • Reject timestamps older than 5 minutes to kill replays.
  • Compare in constant time. === leaks the secret one byte at a time.
  • Rotate the secret from the dashboard; both work for 24 hours.

Retry policy

72 hours

Answer 2xx within 10 seconds or we retry. Queue the work, don't do it in the handler.

10 s1st retry
1 min2nd
10 min3rd
1 h4th
6 h5th
24 h6th
72 hGive up

After 15 consecutive failures we disable the endpoint and email you. Redirects are not followed. Anything other than 2xx — including 3xx — counts as a failure.

Rate limits

Limits by the athlete's plan, not yours

Limits apply per athlete token, so an app with 10,000 Premium users has 10,000 separate 600/15-min budgets. The 15-minute window is a rolling one, and it resets on the quarter hour.

Athlete planShort windowDailyBurstConcurrent
Free
100 / 15 min 1,000 / day 10 / s 2
Premium
600 / 15 min 30,000 / day 20 / s 5
Coach
1,200 / 15 min 100,000 / day 40 / s 10
Organization
Custom Contracted Custom Custom

Headers on every response

X-RateLimit-Limit: 600
X-RateLimit-Remaining: 594
X-RateLimit-Reset: 1784102400
X-RateLimit-Daily-Limit: 30000
X-RateLimit-Daily-Remaining: 28412
X-Request-Id: req_9c33a41f9c2e88b0

Read Remaining on every response and throttle yourself before we do it for you. Reset is a Unix timestamp, not a duration.

Handling 429

429
async function call(url, token, attempt = 0) {
  const res = await fetch(url, {
    headers: { Authorization: 'Bearer ' + token },
  });

  if (res.status !== 429) return res;
  if (attempt >= 5) throw new Error('Rate limited, gave up');

  // Honour Retry-After. Add jitter so your whole fleet
  // does not wake up at the same millisecond.
  const wait = Number(res.headers.get('Retry-After') ?? 60) * 1000;
  const jitter = Math.random() * 1000;

  await new Promise((r) => setTimeout(r, wait + jitter));
  return call(url, token, attempt + 1);
}
Errors

Standard status codes, useful bodies

Every error has a request_id
400
Bad RequestMalformed JSON, or a parameter failed validation.
{
  "error": "invalid_request",
  "message": "distance_km must be greater than 0",
  "field": "distance_km",
  "request_id": "req_9c33a41f9c2e88b0"
}
401
UnauthorizedMissing, malformed or expired access token. Refresh it.
{
  "error": "invalid_token",
  "message": "The access token expired at 2026-07-14T07:42:00Z",
  "request_id": "req_1f5a8e2b7d40c883"
}
403
ForbiddenValid token, but the scope does not cover this call.
{
  "error": "insufficient_scope",
  "message": "This endpoint requires the health:read scope",
  "required_scope": "health:read",
  "granted_scopes": ["read", "activity:read_all"],
  "request_id": "req_6a4c19e5b0d372f1"
}
404
Not FoundNo such object — or it exists and the athlete has hidden it from you.
{
  "error": "not_found",
  "message": "No activity with id act_deadbeef",
  "request_id": "req_42b9c7e1a05d6f38"
}
422
UnprocessableSemantically wrong: a 2:14/km marathon, a future start date, a duplicate upload.
{
  "error": "unprocessable_entity",
  "message": "Activity rejected by the anomaly engine: average pace 2:14/km is not physically plausible",
  "code": "implausible_effort",
  "request_id": "req_e5b0d372f18c2e44"
}
429
Too Many RequestsRate limited. Read Retry-After and back off — do not retry immediately.
{
  "error": "rate_limit_exceeded",
  "message": "600 requests per 15 minutes exceeded",
  "retry_after": 284,
  "limit": 600,
  "remaining": 0,
  "reset": 1784102684,
  "request_id": "req_84210c8834b19e5f"
}
500
Server ErrorOurs, not yours. Retry with backoff; send us the request_id if it persists.
{
  "error": "internal_error",
  "message": "Something broke on our side. This is logged.",
  "request_id": "req_b8d36a4c19e58f2c"
}
503
Service UnavailableMaintenance or an overload shed. Retry-After is always set.
{
  "error": "service_unavailable",
  "message": "Scheduled maintenance until 2026-07-20T03:00:00Z",
  "retry_after": 1800,
  "request_id": "req_0f77b8d36a4c19e5"
}

Quote the request_id when you contact support — it pulls the exact request, response and timing out of our logs in seconds. Without it we are guessing alongside you.

SDKs

Official clients, five languages

Apache-2.0 · open source
🟨
JavaScript / TypeScript v4.2.1 · ⭐ 2.4k

Typed client, auto token refresh, webhook signature helper. Works in Node 18+, Deno, Bun and the browser.

npm install @stridex/sdk
🐍
Python v3.8.0 · ⭐ 1.8k

Sync and async clients, pandas DataFrame export for streams, built-in retry with jitter.

pip install stridex
🍎
Swift v2.4.0 · ⭐ 640

async/await, Codable models, ASWebAuthenticationSession OAuth flow, Keychain token storage.

.package(url: "https://github.com/stridex/stridex-swift")
🤖
Kotlin v2.6.0 · ⭐ 580

Coroutines, OkHttp, kotlinx.serialization. Ships with a Health Connect bridge.

implementation("app.stridex:sdk:2.6.0")
🐹
Go v1.9.2 · ⭐ 910

Context-aware, zero dependencies, streaming decoder for large stream payloads.

go get github.com/stridex/stridex-go
Status

90 days of uptime

All systems operational
90 days ago99.98% uptimeToday
REST API 84ms p50 Operational
OAuth / token service 42ms p50 Operational
Webhook delivery 99.98% 30d Operational
Activity upload & processing 2.1s p95 Operational
Streams & map tiles 112ms p50 Operational
Two amber days: 11 Jun (Frankfurt failover, 34 min elevated latency) and 8 May (streams cache eviction, 12 min). No data lost in either.
Changelog

What changed

v1.24
v1.242 Jul 2026
Added Streams resolution parameter

You can now request low/medium/high resolution on /activities/{id}/streams instead of always paying for every point. High resolution costs 3 rate-limit units per call.

v1.2314 Jun 2026
Changed health:read scope now requires review

Any app requesting health:read goes through a manual review before it can leave development mode. Existing approved apps are unaffected. Sleep and HRV data is not something we hand out on a form submission.

v1.2228 May 2026
Added event.finished webhook

Chip times from integrated timing systems (Chronotrack, MyLaps, RFID) now fire a webhook with full splits within 90 seconds of the finish line read.

v1.219 Apr 2026
Deprecated Deprecating ?access_token= query parameter

Tokens in query strings end up in logs, proxies and browser history. Use the Authorization header. The query parameter stops working on 1 October 2026 — 6% of traffic still uses it.

v1.2017 Feb 2026
Changed Rate limits raised for Premium and Coach

Premium goes 300 → 600 per 15 minutes, Coach 600 → 1,200. Free is unchanged. No action needed; the headers tell you the truth as always.

Full changelog & migration guides

4.6 million athletes are waiting for what you build.

Free to develop against, free to launch, free until you are doing millions of calls a day — and then we will just want to talk to you first.

Org & enterprise API
Install StrideXTrack offline, get live GPS & watch sync.