Documentation

Train it All External API v1 – Client Doc

This document describes how to integrate as a third‑party client using OAuth2 Authorization Code + PKCE and call the token-based External API.

1) Overview

There are two separate things:
(A) Client provisioning (admin): An admin creates an OAuth client in Train it All and gives you a client_id (and optionally a client_secret for confidential clients).
(B) User authorization (runtime): Your app redirects the user to Train it All’s authorization endpoint. After user consent, Train it All redirects back to your redirect_uri with an authorization code. You then exchange that code at the token endpoint for an access_token (+ refresh_token) and call the External API with Authorization: Bearer ….

2) Endpoints

- Authorization endpoint: /oauth/authorize
- Token endpoint: /api/oauth/token
- External API base: /api/external/v1
- FIT upload (create activity from file): /api/external/v1/fit-files

3) OAuth2 Authorization Code + PKCE flow

Use PKCE (S256). Your client must have a registered redirect_uri (exact match).

3.1) Create PKCE verifier/challenge

Example (Node.js) to generate PKCE values:

// Node.js (example)
import crypto from "crypto";

function base64url(buf) {
  return buf.toString("base64").replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,"");
}

const code_verifier = base64url(crypto.randomBytes(32));
const code_challenge = base64url(crypto.createHash("sha256").update(code_verifier).digest());

console.log({ code_verifier, code_challenge, method: "S256" });

3.2) Redirect user to authorize

Build a URL like the following (replace host, client_id, redirect_uri, scopes). Always include a random state.

GET /oauth/authorize?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https%3A%2F%2Fyour.app%2Foauth%2Fcallback
  &scope=profile:read%20activities:read%20activities:write
  &state=RANDOM_STATE
  &code_challenge=PKCE_CODE_CHALLENGE
  &code_challenge_method=S256

3.2.1) Optional: prompt behavior (login/consent)

By default, Train it All will show a consent screen the first time a user authorizes your client. After the user has granted consent, subsequent authorization requests may be auto-approved (no UI) and redirect immediately to your redirect_uri.

If you want to force UI to appear again (e.g. allow switching to another Train it All account), you can use the optional prompt query parameter:

GET /oauth/authorize?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https%3A%2F%2Fyour.app%2Foauth%2Fcallback
  &scope=profile:read%20activities:read%20activities:write
  &state=RANDOM_STATE
  &code_challenge=PKCE_CODE_CHALLENGE
  &code_challenge_method=S256
  &prompt=login%20consent

3.3) Handle redirect

Train it All redirects the user back to your redirect_uri with code and state. Verify that state matches what you sent.

https://your.app/oauth/callback?code=AUTH_CODE&state=RANDOM_STATE

3.4) Exchange code for tokens

Send the code to the token endpoint. Confidential clients should authenticate via Basic Auth. Public clients do not send a secret and rely on PKCE.

Confidential client (recommended):

POST /api/oauth/token
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&
code=AUTH_CODE&
redirect_uri=https%3A%2F%2Fyour.app%2Foauth%2Fcallback&
code_verifier=PKCE_CODE_VERIFIER

Response:

{
  "access_token": "tia_at_…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "tia_rt_…",
  "scope": "profile:read activities:read activities:write"
}

3.5) Refresh token

When the access token expires, use the refresh token. Refresh tokens are rotated (a new refresh token is returned, the old one becomes invalid).

POST /api/oauth/token
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&
refresh_token=OLD_REFRESH_TOKEN

4) Scopes

Scopes are space-separated in scope= and must be allowed by the admin configuration for your client.

5) Calling the External API

Use the access token in an Authorization header:

Authorization: Bearer tia_at_...

5.0) Integration notes / FAQ (read this first)

This section answers the most common integration questions so you do not have to ask them later.

HTTP method convention:
Most “daily” resources are exposed as GET (list) + POST (upsert). We currently do not expose PUT/PATCH for these resources, and we also do not have dedicated “get single day by id” endpoints (clients should list and filter by day on their side).

5.1) Profile

GET /api/external/v1/profile
Authorization: Bearer tia_at_...

Response fields:

Example response:

{
  "id": "cku…",
  "email": "user@example.com",
  "name": "Max Mustermann",
  "image": null,
  "createdAt": "2026-01-17T10:11:12.000Z"
}

5.2) Activities

Supported operations:
- GET: list activities (GET /api/external/v1/activities)
- POST: create a new activity (POST /api/external/v1/activities)
Note: there is currently no separate GET /api/external/v1/activities/<id> endpoint. Use list + store the returned id in your system. For downloading a FIT for a specific activity id, use GET /api/external/v1/activities/<id>/fit.

List:

GET /api/external/v1/activities?page=1&per_page=30
Authorization: Bearer tia_at_...

Query params:

Response: { items: Activity[], pagination: PaginationMeta }. Each item contains (selection):

Example response:

{
  "items": [
    {
      "id": "act_...",
      "organizationId": "org_...",
      "athleteId": "mem_...",
      "source": "EXTERNAL",
      "sourceName": "YOUR_CLIENT_ID",
      "type": "RUN",
      "title": "Evening Run",
      "startedAt": "2026-01-17T18:20:00.000Z",
      "durationSec": 2700,
      "distanceM": 9800,
      "elevationGainM": null,
      "avgHr": 151,
      "maxHr": 172,
      "avgPower": 245,
      "calories": -520,
      "perceivedExertion": 54,
      "notes": null,
      "timezone": "Europe/Berlin",
      "createdAt": "2026-01-17T18:21:00.000Z",
      "externalId": "partner-activity-123",
      "sources": [
        {
          "provider": "EXTERNAL",
          "externalId": "partner-activity-123",
          "rawPayload": {
            "kind": "oauth_external",
            "externalProviderClientId": "YOUR_CLIENT_ID",
            "receivedAt": "2026-01-17T18:21:00.000Z",
            "payload": { "suffer_score": 78 }
          }
        }
      ]
    }
  ],
  "pagination": {
    "page": 1,
    "perPage": 30,
    "prevPage": null,
    "nextPage": 2,
    "hasNextPage": true
  }
}

Supported fields for creating activities (mapped to Train it All’s activity model):

Notes:
- If you provide perceivedExertion on write, it is interpreted as 1..10 (RPE-like scale). If you omit it, Train it All will derive a best-effort internal load score from duration (and calories if available).
- Train it All stores and returns perceivedExertion as a derived internal load score (not 1..10). This means the value you read back in GET (perceivedExertion) is not the same scale.
- calories: you can send a positive number; Train it All may store and return it as negative (internal convention).
- Strava-specific fields like suffer_score are not first-class fields here. You can include them inside rawPayload; Train it All stores them for troubleshooting/processing, but does not automatically interpret them unless implemented.

Create:

POST /api/external/v1/activities
Authorization: Bearer tia_at_...
Content-Type: application/json

{
  "title": "Evening Run",
  "type": "RUN",
  "startedAt": "2026-01-17T18:20:00.000Z",
  "durationMin": 45,
  "distanceKm": 9.8,
  "avgHr": 151,
  "maxHr": 172,
  "avgPower": 245,
  "perceivedExertion": 6,
  "externalId": "partner-activity-123",
  "rawPayload": { "suffer_score": 78, "any": "object" }
}

5.2.1) FIT file upload (create activity from .fit)

If you already have a .fit file, you can upload it to Train it All and we will extract a minimal activity summary from it. This is useful when you do not want to map all fields manually into JSON.

Endpoint:
POST /api/external/v1/fit-files

Auth:
- Requires Authorization: Bearer …
- Requires scope: activities:write

Request format:
- multipart/form-data
- Form field: file (the .fit file)
- Max size: 25 MB
- File name must end with .fit (case-insensitive)

Example (curl):

curl -X POST \
  -H "Authorization: Bearer tia_at_..." \
  -F "file=@activity.fit" \
  https://trainitall.com/api/external/v1/fit-files

Response (new import):

{
  "id": "act_...",
  "storageKey": "fit/<orgId>/<athleteId>/YYYY-MM-DD/<random>.fit"
}

Response (duplicate upload):

{
  "id": "act_...",
  "duplicate": true
}

Deduplication:
Train it All computes a SHA-256 hash of the file and uses it to detect duplicates per user. Uploading the exact same file again will not create a second activity.

What Train it All extracts from the FIT (best effort; depends on what the device recorded):

Source attribution:
- The created activity will have source=EXTERNAL (origin is an external OAuth client).
- We also create an activity source entry with provider=FIT and externalId/checksum set to the file hash (SHA-256).

Important limitations (what a FIT upload does not do in the current API):

5.2.2) FIT file download (get one .fit for an activity)

If you want a .fit for an activity that exists in Train it All, use the activity FIT download endpoint. The External API will return exactly one FIT file per activity.

Endpoint:
GET /api/external/v1/activities/<activityId>/fit

Auth:
- Requires Authorization: Bearer …
- Requires scope: activities:read

Example (curl):

curl -L \
  -H "Authorization: Bearer tia_at_..." \
  -o activity.fit \
  https://trainitall.com/api/external/v1/activities/ACTIVITY_ID/fit

Response:
- 200 OK with Content-Type: application/octet-stream and a .fit file in the body
- 404 Not found if the activity does not exist/is not accessible, or if Train it All cannot provide a FIT for it

Notes (client perspective):

5.2.3) Recommended client flow: upload → store id → download later

Typical integration pattern:

5.3) Body metrics

Supported operations:
- GET: list body metrics (GET /api/external/v1/body-metrics)
- POST: create/update (upsert) a metric (POST /api/external/v1/body-metrics)
Note: there is currently no separate GET /api/external/v1/body-metrics/<id> endpoint in the External API; use list + filter by measuredAt (and/or your stored ids).

List:

GET /api/external/v1/body-metrics?page=1&per_page=30
Authorization: Bearer tia_at_...

Query params:

Response: { items: BodyMetric[], pagination: PaginationMeta } with fields:

Example response:

{
  "items": [
    {
      "id": "bm_...",
      "organizationId": "org_...",
      "athleteId": "mem_...",
      "measuredAt": "2026-01-17T06:30:00.000Z",
      "updatedAt": "2026-01-17T06:35:00.000Z",
      "source": "EXTERNAL",
      "sourceName": "YOUR_CLIENT_ID",
      "weightKg": 78.2,
      "heightCm": 180,
      "bodyFatPct": 12.8,
      "muscleMassKg": null,
      "restingHr": 48,
      "sleepHours": null,
      "notes": null,
      "circumferences": { "waistCm": 82 }
    }
  ],
  "pagination": { "page": 1, "perPage": 30, "prevPage": null, "nextPage": 2, "hasNextPage": true }
}

Create/Upsert:

POST /api/external/v1/body-metrics
Authorization: Bearer tia_at_...
Content-Type: application/json

{
  "measuredAt": "2026-01-17T06:30:00.000Z",
  "weightKg": 78.2,
  "bodyFatPct": 12.8,
  "restingHr": 48
}

Supported write fields (all optional except measuredAt):

Response: { id: string }

5.4) Sleep days

Supported operations:
- GET: list sleep days (GET /api/external/v1/sleep-days)
- POST: create/update (upsert) a day (POST /api/external/v1/sleep-days)
Note: there is currently no separate GET /api/external/v1/sleep-days/<day> endpoint; use list + filter by day.

List:

GET /api/external/v1/sleep-days?page=1&per_page=30
Authorization: Bearer tia_at_...

Query params:

Response: { items: SleepDay[], pagination: PaginationMeta } with fields:

Example response:

{
  "items": [
    {
      "id": "sl_...",
      "organizationId": "org_...",
      "athleteId": "mem_...",
      "day": "2026-01-16",
      "sleepStartAt": "2026-01-15T22:45:00.000Z",
      "sleepEndAt": "2026-01-16T06:15:00.000Z",
      "sleepSeconds": 27000,
      "timeInBedSeconds": 28800,
      "deepSeconds": 5400,
      "lightSeconds": 15000,
      "remSeconds": 4500,
      "awakeSeconds": 900,
      "sleepScore": 82,
      "source": "EXTERNAL",
      "sourceName": "YOUR_CLIENT_ID",
      "updatedAt": "2026-01-16T08:00:00.000Z",
      "rawPayload": { "any": "object" }
    }
  ],
  "pagination": { "page": 1, "perPage": 30, "prevPage": null, "nextPage": 2, "hasNextPage": true }
}

Create/Upsert (unique per day for EXTERNAL):

POST /api/external/v1/sleep-days
Authorization: Bearer tia_at_...
Content-Type: application/json

{
  "day": "2026-01-16",
  "sleepStartAt": "2026-01-15T22:45:00.000Z",
  "sleepEndAt": "2026-01-16T06:15:00.000Z",
  "sleepSeconds": 27000,
  "sleepScore": 82
}

Supported write fields (all optional except day):

Response: { id: string }

5.5) Nutrition days

Supported operations:
- GET: list nutrition days (GET /api/external/v1/nutrition-days)
- POST: create/update (upsert) a day (POST /api/external/v1/nutrition-days)
Note: there is currently no separate GET /api/external/v1/nutrition-days/<day> endpoint; use list + filter by day.

List:

GET /api/external/v1/nutrition-days?page=1&per_page=30
Authorization: Bearer tia_at_...

Query params:

Response: { items: NutritionDay[], pagination: PaginationMeta } with fields (selection):

Example response:

{
  "items": [
    {
      "id": "nu_...",
      "organizationId": "org_...",
      "athleteId": "mem_...",
      "day": "2026-01-16",
      "loggedAt": "2026-01-16T20:00:00.000Z",
      "source": "EXTERNAL",
      "sourceName": "YOUR_CLIENT_ID",
      "updatedAt": "2026-01-16T20:00:00.000Z",
      "energyBreakfastKcal": 450,
      "energyLunchKcal": 650,
      "energyDinnerKcal": 800,
      "energySnackKcal": null,
      "energyTotalKcal": 1900,
      "goalEnergyKcal": 2200,
      "carbTotalG": 210,
      "proteinTotalG": 140,
      "fatTotalG": 60,
      "goalCarbG": 240,
      "goalProteinG": 150,
      "goalFatG": 70,
      "activityEnergyKcal": 420,
      "steps": 8500,
      "waterMl": 2200,
      "rawPayload": { "any": "object" }
    }
  ],
  "pagination": { "page": 1, "perPage": 30, "prevPage": null, "nextPage": 2, "hasNextPage": true }
}

Create/Upsert:

POST /api/external/v1/nutrition-days
Authorization: Bearer tia_at_...
Content-Type: application/json

{
  "day": "2026-01-16",
  "energyBreakfastKcal": 450,
  "energyLunchKcal": 650,
  "energyDinnerKcal": 800,
  "waterMl": 2200,
  "steps": 8500
}

Supported write fields (all optional except day): loggedAt, all energy/macro fields above, activityEnergyKcal, steps, waterMl, plus externalId and rawPayload.

Important: Nutrition is currently unique per user/day (upsert by athleteId + day). This means writing will overwrite the day entry (no multi-provider separation yet).

Response: { id: string }

5.6) Daily wellness

Supported operations:
- GET: list daily wellness days (GET /api/external/v1/daily-wellness)
- POST: create/update (upsert) a day (POST /api/external/v1/daily-wellness)
Note: there is currently no separate GET /api/external/v1/daily-wellness/<day> endpoint; use list + filter by day.

List:

GET /api/external/v1/daily-wellness?page=1&per_page=30
Authorization: Bearer tia_at_...

Query params:

Response: { items: DailyWellness[], pagination: PaginationMeta } with fields:

Example response:

{
  "items": [
    {
      "id": "dw_...",
      "organizationId": "org_...",
      "athleteId": "mem_...",
      "day": "2026-01-16",
      "steps": 8500,
      "waterMl": 2200,
      "activityEnergyKcal": 420,
      "source": "EXTERNAL",
      "sourceName": "YOUR_CLIENT_ID",
      "updatedAt": "2026-01-16T21:00:00.000Z",
      "rawPayload": { "any": "object" }
    }
  ],
  "pagination": { "page": 1, "perPage": 30, "prevPage": null, "nextPage": 2, "hasNextPage": true }
}

Create/Upsert (unique per day for EXTERNAL):

POST /api/external/v1/daily-wellness
Authorization: Bearer tia_at_...
Content-Type: application/json

{
  "day": "2026-01-16",
  "steps": 8500,
  "waterMl": 2200,
  "activityEnergyKcal": 420
}

Supported write fields (all optional except day): steps, waterMl, activityEnergyKcal, plus externalId and rawPayload.

Response: { id: string }

6) Errors

Common responses:
- 401 Unauthorized: missing/invalid/expired token
- 403 Forbidden: missing required scope
- 400 Bad Request: validation error / invalid parameters (typically includes errorCode, hint and details)

Error response format (External API v1, English messages):

{
  "error": "Invalid request body.",
  "errorCode": "validation_error",
  "hint": "Check required fields and units.",
  "details": {
    "validation": { "formErrors": [], "fieldErrors": { "day": ["Invalid"] } },
    "issues": [ /* Zod issues */ ],
    "receivedKeys": ["day", "steps", "extraField"],
    "allowedKeys": ["day", "steps", "waterMl", "activityEnergyKcal", "externalId", "rawPayload"],
    "missingFields": ["day"]
  }
}
Train it All - by Bit-Link