Tests¶
A test is a structured performance evaluation: a lactate test, a VO2max test, an FTP test, or any similar protocol that produces a defined set of physiological markers. Tests live alongside activities and traces, and either can reference a test.
Where a trace is a single point-in-time value and an activity is a continuous recording, a test is the structured output of a deliberate evaluation protocol. The shape holds both interoperable, consumer-facing thresholds (first and second threshold, FatMax) and methodology-specific outputs (LT1 and LT2, VT1 and VT2, MLSS).
Test results¶
The results field on a test is a JSON object. Every field is optional. A test can carry any combination.
Thresholds (consumer-facing)
These are interoperable across testing methodologies. Most apps built on SweatStack should read from these.
| Field | Type | Description |
|---|---|---|
first_threshold |
Marker | The aerobic threshold (LT1 or VT1, depending on the protocol). |
second_threshold |
Marker | The anaerobic threshold (LT2, VT2 or MLSS, depending on the protocol). |
fatmax |
Marker | The point of maximal fat oxidation. |
Tests feed the metabolic map
A test's results become measured markers on the athlete's metabolic map for the test's sport, read per metric from the Marker fields (power, speed, heart_rate) and from the scalar fields (critical_power, heart_rate_max, vo2max, ...). A fresh test (within 90 days; 180 for vo2max) outranks SweatStack's own model; an older one yields to it. Fill the consumer-facing fields yourself. mlss, lt2 and vt2 are published on the map as what they are and never fill second_threshold for you; the same holds for lt1, vt1 and first_threshold. You ran the protocol, so you say which result is the athlete's threshold.
Thresholds (methodology-specific)
For testing protocols where the underlying methodology matters.
| Field | Type | Description |
|---|---|---|
lt1 |
Marker | First lactate threshold. |
lt2 |
Marker | Second lactate threshold. |
vt1 |
Marker | First ventilatory threshold. |
vt2 |
Marker | Second ventilatory threshold. |
mlss |
Marker | Maximal lactate steady state. |
Capacity and ceiling values
| Field | Type | Unit | Description |
|---|---|---|---|
vo2max |
float | mL/min | Confirmed VO2 plateau. |
vo2peak |
float | mL/min | Highest observed VO2. |
vlamax |
float | mmol/L/s | Maximal lactate accumulation rate. |
heart_rate_max |
int | bpm | Observed maximal heart rate. |
critical_power |
int | watts | Critical power (cycling). |
critical_speed |
float | m/s | Critical speed (running). |
w_prime |
float | kJ | W′ (anaerobic work capacity). |
d_prime |
float | meters | D′ (anaerobic distance capacity). |
Economy and efficiency
| Field | Type | Unit | Description |
|---|---|---|---|
economy |
float | mL O₂/kg/km | Running or cycling economy. |
efficiency |
float | % | Gross mechanical efficiency. |
Marker shape¶
A marker is a point on the intensity-duration curve, expressed in any of the metrics SweatStack supports.
{
"power": 285,
"heart_rate": 168,
"speed": 4.2,
"lactate": 4.0,
"vo2": 3200
}
Every field is optional. Fill in the ones the testing protocol measured.
Create a test¶
POST /api/v1/tests/ creates a test.
from datetime import datetime
from zoneinfo import ZoneInfo
import sweatstack
from sweatstack.openapi_schemas import Marker, TestResults
from sweatstack import Client
client = Client()
test = client.tests.create(
sport="cycling.road",
start=datetime(2026, 5, 6, 10, 0, tzinfo=ZoneInfo("Europe/Amsterdam")),
end=datetime(2026, 5, 6, 11, 30, tzinfo=ZoneInfo("Europe/Amsterdam")),
title="Lactate step test",
results=TestResults(
first_threshold=Marker(power=220, heart_rate=150, lactate=2.0),
second_threshold=Marker(power=285, heart_rate=168, lactate=4.0),
vo2max=4100,
),
tags=["lab", "step-test"],
)
tests.create returns the created test. A naive start or end raises ValueError before the request is sent.
curl -X POST "https://app.sweatstack.no/api/v1/tests/" \
-H "Authorization: Bearer {your_access_token}" \
-H "Content-Type: application/json" \
-d '{
"title": "Lactate step test",
"sport": "cycling.road",
"start": "2026-05-06T10:00:00+02:00",
"end": "2026-05-06T11:30:00+02:00",
"results": {
"first_threshold": {"power": 220, "heart_rate": 150, "lactate": 2.0},
"second_threshold": {"power": 285, "heart_rate": 168, "lactate": 4.0},
"vo2max": 4100
},
"tags": ["lab", "step-test"]
}'
Body fields:
sport(required): an Open Sport Taxonomy code. An unrecognized code returns422with codeinvalid_sport.start(required): when the test started. An offset-aware ISO 8601 datetime, for example2026-05-06T10:00:00+02:00or...Z. A datetime without an offset returns422.end(optional): when the test ended, in the same format. If you omit it, SweatStack sets it tostartplus 3 hours.title(optional): a free-text name.results(optional): the object described under Test results.tags(optional): a list of strings.
Requires the data:write scope.
The API returns the created test. SweatStack stores the offset you sent, and every read returns start and end as UTC alongside the local start_local and end_local. See Timezones.
List tests¶
GET /api/v1/tests/ returns tests, filtered by date range, sport, tags and creator.
from datetime import date
from sweatstack import Client
client = Client()
tests = client.tests.list(start=date(2026, 1, 1), sport=["cycling.road"])
tests.list takes the same filters as the endpoint, plus output="pandas" (or "polars") to return a DataFrame.
curl -X GET "https://app.sweatstack.no/api/v1/tests/?start=2026-01-01&sport=cycling.road" \
-H "Authorization: Bearer {your_access_token}"
Query parameters:
start(optional): first day of the range,YYYY-MM-DD. Included. Matches each test's own local date.end(optional): last day of the range,YYYY-MM-DD. Included.sport(optional): a sport code. Repeat the parameter for more than one sport:?sport=cycling.road&sport=running.road.tags(optional): a tag. Repeat the parameter for more than one tag.created_by(optional): the ID of the user or app that created the test.limit(optional, default50): the maximum number of tests to return.offset(optional, default0): the number of tests to skip, for paging.
Requires the data:read scope.
Get a single test¶
GET /api/v1/tests/{test_id} returns the test, plus the traces and activities that belong to it.
from sweatstack import Client
client = Client()
test = client.tests.retrieve(test_id)
linked_only = client.tests.retrieve(test_id, trace_resolution="linked")
tests.retrieve maps trace_resolution to the traces query parameter.
curl -X GET "https://app.sweatstack.no/api/v1/tests/{test_id}" \
-H "Authorization: Bearer {your_access_token}"
Query parameters:
traces(optional, defaultauto): which traces the response includes.autoreturns the traces whose timestamp falls inside the test's time range, plus traces explicitly linked to this test through theirtest_id, minus traces explicitly linked to a different test.linkedreturns only the explicitly linked traces, whatever their timestamp.
Activities are always matched by time overlap. traces does not affect them.
Requires the data:read scope.
The API returns 404 when the test does not exist.
Update a test¶
PUT /api/v1/tests/{test_id} replaces the whole test. It is not a patch: every optional field you leave out is set to null. To change one field, read the test first and send every field back.
from datetime import datetime
from zoneinfo import ZoneInfo
import sweatstack
from sweatstack.openapi_schemas import Marker, TestResults
from sweatstack import Client
client = Client()
client.tests.replace(
test_id,
sport="cycling.road",
start=datetime(2026, 5, 6, 10, 0, tzinfo=ZoneInfo("Europe/Amsterdam")),
title="Lactate step test (revised)",
results=TestResults(second_threshold=Marker(power=290, heart_rate=170, lactate=4.0)),
)
tests.replace takes the same arguments as tests.create.
curl -X PUT "https://app.sweatstack.no/api/v1/tests/{test_id}" \
-H "Authorization: Bearer {your_access_token}" \
-H "Content-Type: application/json" \
-d '{
"title": "Lactate step test (revised)",
"sport": "cycling.road",
"start": "2026-05-06T10:00:00+02:00",
"results": {
"second_threshold": {"power": 290, "heart_rate": 170, "lactate": 4.0}
}
}'
The body takes the same fields as Create a test.
Requires the data:write scope.
Errors:
403: a different app created the test, and your token has no access to it.404: the test does not exist.
Delete a test¶
DELETE /api/v1/tests/{test_id} deletes the test.
from sweatstack import Client
client = Client()
client.tests.delete(test_id)
curl -X DELETE "https://app.sweatstack.no/api/v1/tests/{test_id}" \
-H "Authorization: Bearer {your_access_token}"
Requires the data:write scope.
Errors are the same as for Update a test: 403 and 404.
App metadata¶
Your app can attach its own JSON to a test with PUT /api/v1/tests/{test_id}/app-metadata and delete it with DELETE /api/v1/tests/{test_id}/app-metadata. See Application metadata for the full pattern. It is the same for activities, traces and tests.