Skip to content

Metabolic profile

Beta

Zones, the metabolic map, the intensity-duration model and the marker store are in beta. We can release breaking changes to them with little notice. If you plan to use them in production, email [email protected] before you integrate. We'll then tell you before a breaking change is released and help you migrate.

Metabolic profiling is the process of inferring an athlete's physiology. SweatStack does this from the athlete's activity history, and from what the athlete, their coach, a lab test or their device told us. Out of it come the athlete's training zones and the metabolic map they are cut from: a compact set of physiological markers your app can build on, each labelled with where it came from.

Demonstrated performance, not potential

The model works from activity data, so it assumes the athlete has produced maximal efforts across a range of durations. It reports what they have demonstrated, not what they are capable of. Steady training with no hard efforts yields modeled markers that are too low, and the data cannot flag the difference. The map says which markers are modeled; if your athletes don't test or race, let them store a max effort or present modeled markers as "based on your training so far".

Which endpoint to call

Five endpoints, each built on the one below it. Most apps need only the first.

Endpoint What you get Use it when
Zones
GET /api/v1/profile/zones/{metric}
Zone definitions, ready to use, resolved against the athlete's data. You want to show your users their training zones, prescribe workouts, or calculate time in zone.
Metabolic map
GET /api/v1/profile/metabolic-map/{metric}
The athlete's markers, each with its provenance. Your app uses its own zone model, or you want to follow markers over time.
Marker store
/api/v1/profile/markers/{marker}, /api/v1/profile/max-efforts
What the athlete, their coach, your app or a device told us, exactly as written. You let users enter an FTP, a max heart rate or a race result.
Intensity-duration model
GET /api/v1/profile/intensity-duration-model/{metric}
An envelope fit of the intensity-duration curve across a range of durations. You want to plot a power-duration curve, or you need a best-effort estimate at a specific duration.
Mean-max
GET /api/v1/activities/longitudinal-mean-max
The raw historical mean-max data. You are training your own intensity-duration models.

One URL rule. Under /profile, whatever decides the response shape is the last path segment: the metric on the map, the zones and the intensity-duration model (/zones/power, /metabolic-map/heart_rate), the marker on the store (/markers/second_threshold). Whatever only selects values is a query parameter: sport, date, zone_system. Each operation has one exact schema, so the generated SDK is typed.

Zones

GET /api/v1/profile/zones/{metric} returns an athlete's training zones as absolute values, cut from the metabolic map for the same sport and date. {metric} is power, speed, heart_rate, rpe_cr10 or rpe_borg.

curl -X GET "https://app.sweatstack.no/api/v1/profile/zones/power?sport=cycling&zone_system=five_zone&date=2026-09-01" \
    -H "Authorization: Bearer {your_access_token}"

Query parameters:

  • sport (required for power, speed and heart rate): a root sport. power takes cycling or running, speed takes running, heart_rate takes any root sport (running, cycling, xc_skiing, ...). Subsports are not accepted: every activity of the sport is used. RPE takes no sport.
  • zone_system (optional): three_zone, five_zone or seven_zone for power and speed (defaults: seven_zone for power, five_zone for speed); five_zone for heart rate; olt_i_scale for RPE. Each operation's enum is exactly the systems that metric allows.
  • date (optional, default today): the zones as of this date, YYYY-MM-DD. Included. RPE takes no date.

Requires the data:read scope.

{
    "zone_system": "five_zone",
    "zones": [
        {"name": "Z1", "label": "Recovery",  "lower": 0.0,   "upper": 122.0},
        {"name": "Z2", "label": "Endurance", "lower": 122.0, "upper": 152.0},
        {"name": "Z3", "label": "Tempo",     "lower": 152.0, "upper": 248.0},
        {"name": "Z4", "label": "Threshold", "lower": 248.0, "upper": 276.0},
        {"name": "Z5", "label": "VO2max",    "lower": 276.0, "upper": 330.0}
    ]
}

Zones are ordered from low to high. name is stable across calls and across zone systems, so branch on name. label is for display only. Zones carry no provenance: which markers they were cut from is the method's business, and the map tells you.

Power and speed zones are cut from the map's fatmax, second_threshold and maximal aerobic intensity:

  • Values are in whole W or in m/s to 2 decimals, the same precision as the map. Pace formatting is up to you.
  • A zone holds lower <= x < upper. Each upper is the next zone's lower, and the first lower is 0.
  • three_zone and five_zone end at maximal aerobic intensity. A sample above it falls outside every zone, and your app decides where to place it. The top zone of seven_zone (Z7, Sprint) is open: its upper is null, and it holds everything above its lower.

Heart-rate zones are five bands of heart-rate reserve (Karvonen): a boundary at p % is resting_hr + p × (heart_rate_max − resting_hr), at 60, 70, 80 and 90 %, in whole bpm. Z1 starts at 0 so every sample bins, and Z5 is open because a stale max heart rate is exceeded in practice. heart_rate_max is the map's resolved marker (what the athlete, their coach, a test or their device said; SweatStack has no heart-rate model yet) and resting_hr is the 7-day median of the athlete's resting_hr dailies. These bands are population conventions, not the athlete's own thresholds: the map shows the athlete's threshold heart rate, the zones do not use it yet.

RPE zones need no training data and no body mass, so they work for athletes without a power meter and for athletes who signed up yesterday. They follow Olympiatoppen's I-scale, zones I-1 to I-5, as published. Values are integer scale points, and both lower and upper are included. On CR10, 2 sits in both I-1 and I-2, and 0 is in no zone, exactly as the scale defines it.

{
    "zone_system": "olt_i_scale",
    "zones": [
        {"name": "I-1", "label": "Very light",    "lower": 1, "upper": 2},
        {"name": "I-2", "label": "Fairly light",  "lower": 2, "upper": 3},
        {"name": "I-3", "label": "Somewhat hard", "lower": 4, "upper": 5},
        {"name": "I-4", "label": "Hard",          "lower": 6, "upper": 7},
        {"name": "I-5", "label": "Very hard",     "lower": 8, "upper": 10}
    ]
}

Every power or speed call fits the athlete's metabolic model, the same work the map does. Zones move on the scale of weeks, not days: cache them per athlete per day.

Building a workout? Turn a zone into a range target with its lower and upper. The athlete's device resolves a workout zone target against the device's own zones, not these.

Errors are application/problem+json with a code:

  • unsupported_zone_system: that zone system isn't offered for the metric, like olt_i_scale for power (an unknown system is an ordinary validation error).
  • missing_body_mass and insufficient_data: as on the map. Power and speed only.
  • missing_heart_rate_max, missing_resting_hr, unusable_heart_rate_range: heart rate only. Store a max heart rate with the marker store, log a resting heart rate as a daily.

A new athlete on power gets insufficient_data or missing_body_mass. Branch on code to ask for a test, ask for their body mass, or fall back to RPE or heart-rate zones.

The metabolic map

The layer under the zones: the markers themselves, resolved. One call returns every marker for a sport and metric, as of a date, and says where each one came from.

curl -X GET "https://app.sweatstack.no/api/v1/profile/metabolic-map/power?sport=cycling&date=2026-09-01" \
    -H "Authorization: Bearer {your_access_token}"

{metric} is power, speed, vo2 or heart_rate. Query parameters:

  • sport (required): a root sport. power and vo2 take cycling or running, speed takes running, heart_rate takes any root sport. Every activity of the sport is used, so subsports like cycling.road are not accepted.
  • date (optional, default today): the map as of this date, YYYY-MM-DD. Included.

Requires the data:read scope.

{
    "inputs": {"body_mass": 72.4},
    "markers": {
        "fatmax":            {"value": 152.0, "provenance": "modeled"},
        "first_threshold":   {"value": 152.0, "provenance": "modeled"},
        "second_threshold":  {"value": 270.0, "provenance": "measured"},
        "critical_power":    {"value": 284.0, "provenance": "modeled"},
        "max_aerobic_power": {"value": 330.0, "provenance": "modeled"},
        "w_prime":           {"value": 18.3,  "provenance": "modeled"},
        "mlss":              {"value": 268.0, "provenance": "measured"},
        "lt1": null, "lt2": null, "vt1": null, "vt2": null
    }
}

Every marker is null or {"value": ..., "provenance": ...}. Nothing else: no date, no method, no test id. The key set per metric is fixed, so a missing value is one null and the SDK is typed.

Provenance: where a value came from

provenance Meaning
modeled SweatStack's model, fitted to the athlete's recent training (currently the 90 days up to date; how much history we use is part of the model and can change).
measured A test: a lab or field protocol performed at a time.
declared A person set the value: the athlete, or a coach on a delegated token.
external Another system's value whose method we cannot see: a device setting, a device's auto-detection, a third-party estimate.

Which value wins. For one marker, one metric, one sport, as of date: a fresh measured or declared value (the most recent; on the same date measured wins), then the model, then a stale measured or declared value, then external, then null. "Fresh" is 90 days for the thresholds, critical power and FatMax, 180 days for VO2max, and unlimited for heart_rate_max; a declared value can be pinned to stay fresh until it is changed. On the day a value goes stale the map's provenance flips, visibly, from measured or declared to modeled.

Use the label for honest captions ("from your lab test on 2 September" versus "estimated from training"), for policy (your app may ignore external), and for safe pairing: w_prime from the model belongs with critical_power from the model, not with a lab second_threshold.

The markers

Markers use the names tests use, so an app that knows tests knows the map. Concept names are the contract; how we estimate a modeled value is our method and can change.

Marker Metrics What it is
second_threshold power, speed, heart rate The threshold: FTP in W, threshold pace in m/s, LTHR in bpm. Build zones and targets on this. Modeled as the maximal lactate steady state estimated from critical power.
first_threshold power, speed, heart rate The aerobic threshold, the top of the low-intensity domain. Modeled as FatMax.
fatmax power, speed Intensity at maximum fat oxidation. null when fat oxidation never peaks inside the modeled range.
critical_power, critical_speed power, speed The asymptote of the intensity-duration curve. Critical swim speed is critical_speed.
max_aerobic_power, max_aerobic_speed power, speed The intensity at which oxygen uptake reaches VO2max.
w_prime, d_prime power, speed The finite work (kJ) or distance (m) capacity above the critical intensity, from the same fit as critical_power. null when the fit could not determine it. More sensitive than the threshold to whether the athlete went hard.
heart_rate_max heart rate Maximum heart rate. Never modeled today.
vo2max vo2 Maximum oxygen uptake, absolute (mL/min). One per sport: running from speed, cycling from power. Divide by inputs.body_mass for mL/kg/min.
mlss, lt1, lt2, vt1, vt2 power, speed, heart rate Methodology markers: a measured MLSS, lactate or ventilatory threshold. Present only when a test reported it or the athlete entered a lab result, never modeled, and never used to fill second_threshold: the producer of a test says which result is the athlete's threshold.

inputs are the dailies the map used: body_mass (kg to 1 decimal, the daily on date estimated from the nearest records) for power, speed and vo2; resting_hr (the 7-day median ending on date, used by the heart-rate zones) for heart rate.

Intensities are in whole W, m/s to 2 decimals or whole bpm; vo2max in whole mL/min; w_prime in kJ to 1 decimal; d_prime in whole metres. The markers are estimates with an error of a few percent, so they carry no digits beyond that. Markers are per sport: an athlete's cycling and running values are profiled separately, and you ask for one sport per call.

Errors are application/problem+json with a code:

  • missing_body_mass: there is no body mass to scale the markers with. Log one with POST /api/v1/dailies/body_mass. Power, speed and vo2 only.
  • insufficient_data: no activity in the athlete's recent training lasted 15 minutes. Power, speed and vo2 only; a stored max effort of 15 minutes or longer satisfies it.
  • unsupported_sport: not a root sport, or the metric's map does not exist for it. Heart rate takes any root sport; an unsupported sport on the other metrics is an ordinary validation error, since each operation's sport is an enum.

Monitoring progression. Call the map with a series of date values to get a trend. Weekly or monthly points are enough to show it. A drop in a modeled marker usually means the athlete stopped going hard, not that they lost fitness. A step is a test or a declared value winning, or expiring.

Storing markers

The map is the resolved picture: it may differ from anything you wrote, and it says where each value came from. The store is what athletes, coaches, apps and devices told us, returned exactly as written. A consumer who writes a value for day X and reads it back gets it back from the store; the map may show something else that day.

POST /api/v1/profile/markers/{marker} stores a value. {marker} is any marker name from the table above. Requires data:write.

curl -X POST "https://app.sweatstack.no/api/v1/profile/markers/second_threshold" \
    -H "Authorization: Bearer {your_access_token}" \
    -H "Content-Type: application/json" \
    -d '{"sport": "cycling", "metric": "power", "value": 280, "date": "2026-06-01", "provenance": "declared"}'
  • sport: a root sport. Markers are per sport; "all my sports" is your app writing once per sport.
  • metric: power, speed or heart_rate, one the marker is expressed in.
  • value: in the metric's canonical unit (W, m/s, bpm).
  • date: when the value was determined: set, read from a device, estimated.
  • provenance (required, no default): declared when a person entered it, external when your platform relays a value it did not produce (a device setting, another platform's estimate). Measured values are test results: write a test and it feeds the map from there (422 measured_is_a_test).
  • pinned (optional, declared only): keep the value until it is changed. A pinned value never goes stale; a newer declared value or test still replaces it.
{
    "id": "01KMZ…",
    "sport": "cycling", "marker": "second_threshold", "metric": "power",
    "value": 280.0, "date": "2026-06-01",
    "provenance": "declared", "pinned": false,
    "source": null, "application_id": "01KKK…", "activity_id": null,
    "created_at": "2026-06-01T18:04:11Z"
}

Writing the same sport, metric and date again replaces the value and answers 200 with the same id; a new identity answers 201. application_id is your app; source and activity_id are set only on values SweatStack read from an integration or an activity file. There is no acting-user field: a coach's write on a delegated token is stored like the athlete's own.

GET /api/v1/profile/markers/{marker}?sport=&metric=&start=&end= returns the rows as written, newest first; every filter is optional. DELETE /api/v1/profile/markers/{marker}/{id} removes one. Requires data:read to read, data:write to delete.

Errors are application/problem+json with a code: unsupported_marker_metric (heart_rate_max in power), measured_is_a_test, unsupported_provenance, unsupported_sport, pinned_requires_declared. A missing provenance, a non-positive value, an unknown marker or metric are ordinary validation errors.

Max efforts

An all-out effort the athlete knows they can hold, as a point on the intensity-duration curve, with no activity behind it. Efforts in the profile window are injected into the fit before the markers are modeled: per duration the higher of the training data and the effort counts, so an effort below what training already shows changes nothing, and modeled markers stay modeled. Efforts are used as truth: ask your users to enter only what they can hold. An effort expires with the window (90 days); the raw mean-max endpoints never show it.

# A race: distance and time; the speed is derived.
curl -X POST "https://app.sweatstack.no/api/v1/profile/max-efforts" \
    -H "Authorization: Bearer {your_access_token}" -H "Content-Type: application/json" \
    -d '{"sport": "running", "metric": "speed", "date": "2026-09-14", "distance": 10000, "duration": 2550, "provenance": "measured", "title": "Oslo 10K"}'

# An estimate: duration and value.
curl -X POST "https://app.sweatstack.no/api/v1/profile/max-efforts" \
    -H "Authorization: Bearer {your_access_token}" -H "Content-Type: application/json" \
    -d '{"sport": "cycling", "metric": "power", "date": "2026-09-20", "duration": 1200, "value": 300, "provenance": "declared"}'

Exactly one of the two forms per request. metric is power or speed (the curve the effort is injected into). provenance is measured (it happened and was timed) or declared (an estimate). title is for recognition only. The response carries value in the metric's unit, derived for a race. GET /api/v1/profile/max-efforts?sport=&metric=&start=&end= lists them, DELETE /api/v1/profile/max-efforts/{id} removes one.

Efforts of 5 to 20 minutes move the threshold most: that is the band the critical-power model is fitted on. A longer race mostly raises the shorter durations to its own value.

Errors: invalid_max_effort_form (neither or both forms, or a distance for power), unsupported_provenance, unsupported_sport.

What it's built on

The zones and the markers sit on two lower steps, all of it powered by SweatStack Profiling. Each step is its own endpoint. Use them when you want to draw the curve yourself, fit your own model, or inspect the data behind a marker.

The intensity-duration model, a fitted curve. GET /api/v1/profile/intensity-duration-model/{power|speed}?sport=&date=&durations= fits a smooth curve to the upper edge of the mean-max data. The curve sits on the athlete's proven bests and never predicts them weaker than they have already shown, and you can read a ceiling at any duration. The map's modeled markers are read off this curve. The interactive demo shows how the fit works.

Mean-max, the raw data. GET /api/v1/activities/longitudinal-mean-max returns the best value the athlete sustained at every duration over a period, with no model applied and no stored max efforts. This is ground truth: what they did. See Mean-max.

Full request and response schemas and error codes are in the Profile API reference. Run the endpoints live against your own account in the API playground.