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=S2563.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:
prompt=login– always show the login screen (useful to switch accounts)prompt=consent– always show the consent screen (even if previously approved)- You can combine them:
prompt=login%20consent
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%20consent3.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_STATE3.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_VERIFIERResponse:
{
"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_TOKEN4) Scopes
Scopes are space-separated in scope= and must be allowed by the admin configuration for your client.
profile:readactivities:read,activities:writebody:read,body:writesleep:read,sleep:writenutrition:read,nutrition:writedailyWellness:read,dailyWellness:write
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.
- Do you provide “deep data” (laps/trackpoints/streams)?
No. The External API exposes a summary activity model (duration, distance, HR, power, etc.). We do not expose laps/trackpoints as first‑class API resources at this time.
If you need a file representation, use the FIT download endpoint:GET /api/external/v1/activities/<activityId>/fit. - Can we download the raw original FIT (or other files) for an activity?
You can download a.fitfor an activity viaGET /api/external/v1/activities/<activityId>/fit. The External API will return exactly one FIT per activity. Treat it as opaque binary.
We do not expose any endpoint to download arbitrary files by internal keys (no “storageKey” API), and we do not provide other file types at the moment. - Is there anything in rawPayload that we should parse?
No.rawPayloadis intended as an opaque JSON blob for troubleshooting / optional partner-specific fields.
When you write a resource, you may includerawPayloadand we store it. When you read resources back, you may see wrapper metadata (e.g. partner attribution) plus your original payload. Do not build business logic that depends on Train it All’s internal structure of that wrapper. - Do you reject unknown JSON keys?
No. For External API writes, Train it All ignores unknown keys so you can send your full provider payload without pruning it.
If validation fails, the API typically returns400with an additionaldetailsobject to help you identify the field that caused the error. - How do list endpoints paginate?
List endpoints use Strava-style pagination viapage(1-based) +per_page.
Defaults/limits:per_pagedefaults to 30 and is capped at 200.
Backwards compatibility:limitis a deprecated alias forper_page(useper_pagegoing forward).
Responses include apaginationobject plus headers:X-Page,X-Per-Page,X-Prev-Page,X-Next-Page,X-Has-Next-Page.
Recommended client loop: start atpage=1, then keep requestingpage=pagination.nextPageuntilpagination.hasNextPageisfalse. - To create new activities, do you just want summary data like in the example?
Yes. You have two options:
- JSON summary:POST /api/external/v1/activities
- FIT upload (summary extracted):POST /api/external/v1/fit-files - Body metrics: what is the difference between source and sourceName?
sourceis the technical origin (e.g.EXTERNAL,GARMIN,STRAVA, …).sourceNameis a display-friendly attribution string. ForEXTERNALdata it resolves to your OAuthclient_id(so users can see which partner provided the data). For non-external sources it may simply equalsource. - Body metrics: we send Sleep and Weight in separate calls – will you “join” them?
Body metrics are upserted bymeasuredAt. If you send weight in one call and sleep-related fields in another call, you should use thesamemeasuredAttimestamp (e.g.YYYY-MM-DDT00:00:00Zor another deterministic timestamp) so that both calls update the same record.
Omitted fields do not overwrite existing values (partial updates are supported).
Note: for sleep, we strongly recommend using/api/external/v1/sleep-daysinstead ofsleepHoursin body metrics. - Sleep days: what should day mean (e.g. bedtime at 1AM)?
dayis the calendar day the sleep should be attributed to (YYYY-MM-DD). Use a consistent rule in the user’s local timezone. Recommended: use the day of sleepEndAt (wake-up date).
Example: if a user goes to bed at 01:00 and wakes up later the same morning,dayshould be that same date. - Is sleepScore optional?
Yes.sleepScoreis optional and may be omitted. - Nutrition days: one record per day or per meal/entry?
Nutrition is currently modeled as one record per user/day (day=YYYY-MM-DD). If your system stores meals as separate entries, please aggregate them into daily totals/breakdowns before callingPOST /api/external/v1/nutrition-days.
If you must send multiple updates throughout the day, send the latest full daily totals each time. - Nutrition example contains steps – is that a typo?
No.steps(andwaterMl) exist on both Nutrition Day and Daily Wellness for convenience, because some providers deliver them together with nutrition.
Recommended: send steps/water via/api/external/v1/daily-wellnessand keep/nutrition-daysfocused on nutrition totals.
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:
id(string) – Train it All user idemail(string)name(string | null)image(string | null)createdAt(ISO string)
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:
page(int, optional, default 1)per_page(int, optional, default 30, max 200)limit(int, optional, deprecated alias forper_page)
Response: { items: Activity[], pagination: PaginationMeta }. Each item contains (selection):
id,organizationId,athleteId(string)type(enum:RUN|RIDE|SWIM|STRENGTH|OTHER)title(string)startedAt,createdAt(ISO string)durationSec(int, seconds)durationMin(number | null, minutes) – convenience field derived fromdurationSec / 60distanceM(number | null, meters)distanceKm(number | null, kilometers) – convenience field derived fromdistanceM / 1000elevationGainM(number | null, meters)avgHr,maxHr(int | null, bpm)avgPower(int | null, watts)calories(int | null) – note: may be negative (internal convention)perceivedExertion(int) – internal derived load points (not 1..10)notes,timezone(string | null)source(enum) andsourceName(string) –sourceNameresolves your partnerclientIdexternalId(string | null) – partner-provided stable id (if provided on write)sources(array, 0..1) – latest source attribution:provider(enum)externalId(string | null)rawPayload(object | null) – containsexternalProviderClientIdand optionalpayload
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):
title(string, required)type(enum, required)startedAt(ISO string recommended, or Unix timestamp as number (seconds or ms); required)durationMin(int, minutes) – required unless you providedurationSec- Alternative:
durationSec(int, seconds) – required unless you providedurationMin distanceKm(number, optional)- Alternative:
distanceM(number, optional, meters) elevationGainM(number, optional)avgHr,maxHr(int, optional)avgPower(int, optional)calories(int, optional)perceivedExertion(int 1..10, optional; if provided it is used to derive an internal load score)notes,timezone(string, optional)externalId(string, optional; stored for debugging/idempotency)rawPayload(any JSON, optional; stored as-is for later troubleshooting/processing)
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-filesResponse (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):
- Activity type: derived from FIT
sport/sub_sportand mapped to ourActivityType(RUN/RIDE/SWIM/STRENGTH/OTHER). - Start time:
startedAtfromstart_time(fallbacks apply). - Duration:
durationSecfromtotal_timer_timeortotal_elapsed_time. - Distance:
distanceMfromtotal_distance(if present). - Elevation:
elevationGainMfromtotal_ascent(if present). - Heart rate:
avgHr/maxHr(if present). - Power:
avgPower(if present). - Calories:
calories(if present). - Load / exertion:
perceivedExertionis derived as internal load points (best-effort fallback). If the FIT has no usable energy data, this may be missing. - Title: derived from the filename (best effort).
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):
- No “download by storageKey” endpoint: Train it All does not expose a public endpoint that lets you download arbitrary files by an internal
storageKey.
If you uploaded a FIT and later want a FIT again, use the activity FIT download endpoint:GET /api/external/v1/activities/<activityId>/fit. - No full “deep data” contract: we currently extract a summary only. We do not expose streams/laps/trackpoints as first-class API objects.
- No notes/timezone/RPE input: FIT files typically do not contain user notes, an IANA timezone, or subjective RPE (1..10). Those fields will be missing unless provided via the JSON activity endpoint.
- No sleep/nutrition/body/wellness import via FIT: uploading a FIT creates an activity only. For other data types use the dedicated endpoints:
- Sleep:
/api/external/v1/sleep-days - Nutrition:
/api/external/v1/nutrition-days - Daily wellness:
/api/external/v1/daily-wellness - Body metrics:
/api/external/v1/body-metrics
- Sleep:
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/fitResponse:
- 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):
- One FIT per activity: even if an activity has multiple origins/providers inside Train it All (dedup/merged sources), this endpoint returns a single FIT file for that activity.
- Best available FIT: Train it All will return the best available FIT for the activity. You should treat it as opaque binary.
- Caching: responses are non-cacheable (
Cache-Control: no-store).
5.2.3) Recommended client flow: upload → store id → download later
Typical integration pattern:
- Upload: Send
POST /api/external/v1/fit-fileswith a FIT file. Store the returnedid(activity id) in your system. - Read metadata: Use
GET /api/external/v1/activitiesto list activities and keep your mapping updated. - Download: When you need the FIT file again, call
GET /api/external/v1/activities/<id>/fit.
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:
page(int, optional, default 1)per_page(int, optional, default 30, max 200)limit(int, optional, deprecated alias forper_page)
Response: { items: BodyMetric[], pagination: PaginationMeta } with fields:
id,athleteId,organizationId(string)measuredAt,updatedAt(ISO string)source(string | null) andsourceName(string | null)weightKg,bodyFatPct,muscleMassKg,sleepHours(number | null)heightCm,restingHr(int | null)notes(string | null)circumferences(object | null):neckCm,chestCm,waistCm,hipCm,thighCm,armCm(number | null)
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):
measuredAt(ISO string, required)weightKg,bodyFatPct,muscleMassKg,sleepHours(number, optional)heightCm,restingHr(int, optional)notes(string, optional)neckCm,chestCm,waistCm,hipCm,thighCm,armCm(number, optional)externalId(string, optional)rawPayload(any JSON, optional)
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:
page(int, optional, default 1)per_page(int, optional, default 30, max 200)limit(int, optional, deprecated alias forper_page)
Response: { items: SleepDay[], pagination: PaginationMeta } with fields:
id,athleteId,organizationId(string)day(string,YYYY-MM-DD)sleepStartAt,sleepEndAt(ISO string | null)sleepSeconds,timeInBedSeconds,deepSeconds,lightSeconds,remSeconds,awakeSeconds(int | null)sleepScore(int | null)source(string | null),sourceName(string | null),updatedAt(ISO string)rawPayload(object | null)
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):
day(string,YYYY-MM-DD, required)sleepStartAt,sleepEndAt(ISO string, optional)sleepSeconds,timeInBedSeconds,deepSeconds,lightSeconds,remSeconds,awakeSeconds(int, optional)sleepScore(int, optional)externalId(string, optional)rawPayload(any JSON, optional)
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:
page(int, optional, default 1)per_page(int, optional, default 30, max 200)limit(int, optional, deprecated alias forper_page)
Response: { items: NutritionDay[], pagination: PaginationMeta } with fields (selection):
id,athleteId,organizationId(string)day(string,YYYY-MM-DD)loggedAt(ISO string | null),updatedAt(ISO string)source(string | null),sourceName(string | null)rawPayload(object | null)- Energy (kcal):
energyBreakfastKcal,energyLunchKcal,energyDinnerKcal,energySnackKcal,energyTotalKcal,goalEnergyKcal - Macros (g):
carbTotalG,proteinTotalG,fatTotalG,goalCarbG,goalProteinG,goalFatG - Other:
activityEnergyKcal(int | null),steps(int | null),waterMl(int | null)
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:
page(int, optional, default 1)per_page(int, optional, default 30, max 200)limit(int, optional, deprecated alias forper_page)
Response: { items: DailyWellness[], pagination: PaginationMeta } with fields:
id,athleteId,organizationId(string)day(string,YYYY-MM-DD)steps,waterMl,activityEnergyKcal(int | null)source(string | null),sourceName(string | null),updatedAt(ISO string)rawPayload(object | null)
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"]
}
}