Skip to content

Activities

An activity is a recorded session: what the athlete did, with a start, an end and timeseries. This page covers how to read activities, their timeseries and the curves SweatStack computes from them, and how to upload activity files. The entities and their fields are described in the data model.

Response formats

Activity timeseries (position, heart rate, power, and the other metrics at sample rate) is delivered as Apache Parquet, both per activity (the activity data endpoint) and across activities (longitudinal data). Activity metadata (sport, distance, duration, and so on) is returned as JSON.

Three properties of the Parquet timeseries matter before you build on it:

  • Compression. Files are ZSTD compressed. Any Parquet reader with ZSTD support reads them, and most current readers include it.
  • Sample rate. Samples are usually at 1 Hz (one row per second), but SweatStack does not guarantee it: some devices and sports record at a different or variable rate. Read the timestamps instead of assuming a fixed interval.
  • Shape. One row per sample, a timestamp column, and one column per metric. See the data model for the metric columns.

The Python SDK handles all of this for you and returns a pandas DataFrame. If you read the Parquet bytes yourself, see Reading the Parquet outside the Python SDK. If your stack has no good Parquet reader, email us. We want to hear about your use case.

Activity metadata

GET /api/v1/activities/ lists activities with their metadata: id, sport, start and end, summary statistics, and so on. The timeseries is not included.

from sweatstack import Client

client = Client()

activities = client.activities.list()

activities.list returns the 100 most recent activities by default (the SDK's own default, not the API's) and takes the same filters as the endpoint. Pass output="pandas" (or "polars") to get a DataFrame instead of a list:

activities = client.activities.list(output="pandas")

In the DataFrame, start and end are one datetime64[ns, UTC] column each, so the usual pandas datetime operations work directly. start_local and end_local are naive columns holding the athlete's wall-clock time with the UTC offset already applied. Use those for display and for grouping by local calendar day.

A common use of the DataFrame is a rolling calculation over activity start times, for example a 7-day rolling sum of distance. Roll on start_local, so each activity counts on the local day the athlete trained:

activities.rolling("7d", on="start_local")["summary.distance"].sum()
curl -X GET "https://app.sweatstack.no/api/v1/activities/" \
    -H "Authorization: Bearer {your_access_token}"

The API returns the 50 most recent activities by default. Narrow the list with query parameters:

curl -X GET "https://app.sweatstack.no/api/v1/activities/?limit=100&offset=0&start=2024-01-01&end=2024-12-31&sport=running&sport=cycling" \
    -H "Authorization: Bearer {your_access_token}"

Query parameters:

  • start (optional): first day of the range, YYYY-MM-DD. Included. Matches each activity's own local date. See Date-range filtering.
  • end (optional): last day of the range, YYYY-MM-DD. Included.
  • limit (optional, default 50): the maximum number of activities to return. There is no upper bound.
  • offset (optional, default 0): the number of activities to skip, for paging.
  • sport (optional): a sport code. Repeat the parameter for more than one sport: ?sport=cycling&sport=running.road. Filter on the server with this parameter instead of filtering a larger list in your app.
  • tags (optional): a tag. Repeat the parameter for more than one tag.

Requires the data:read scope.

Computing your own per-activity values? See custom calculations on activities. Most apps don't need a local database.

Response shape

The list response is an array of activity summary objects. One realistic item:

Example response (one activity)
[
  {
    "id": "act_01HV3KJD9M2P4S7T8WXQR0YZ",
    "sport": "running.road",
    "start": "2026-05-18T15:32:00Z",
    "end": "2026-05-18T16:18:14Z",
    "start_local": "2026-05-18T17:32:00",
    "end_local": "2026-05-18T18:18:14",
    "duration": "PT46M14S",
    "metrics": ["power", "speed", "heart_rate", "cadence", "distance"],
    "source_id": "src_01HV3KJB7XQ2N1F0G8YH9CDM",
    "tags": ["easy"],
    "summary": {
      "power":      { "mean": 248,   "max": 412 },
      "speed":      { "mean": 3.5,   "max": 4.9 },
      "heart_rate": { "mean": 152,   "min": 92,  "max": 174, "start": 110, "end": 158 },
      "distance":   { "sum": 9712 },
      "altitude":   { "mean": 142,   "gain": 78, "loss": 81, "min": 96,    "max": 188 },
      "cadence":    { "mean": 178,   "max": 192 }
    },
    "laps": null,
    "traces": null,
    "app_metadata": null
  }
]

The fields you'll use most:

Path Description
id Stable activity ID. Use it for the /activities/{id}/... endpoints.
sport Normalized SweatStack sport. See sports.
start, end Absolute UTC instant, ISO 8601 with a Z suffix. See Timezones.
start_local, end_local Naive local wall-clock strings, for display and date grouping.
duration ISO 8601 duration, computed as end minus start.
metrics The metric names recorded in the timeseries. Check it before you fetch the timeseries.
summary.* Per-metric aggregates. See summary statistics for the full set of paths. A metric the activity did not record is absent or null.
laps[] Always null on the list. GET /api/v1/activities/{id} and /activities/latest return the laps when the source recording had lap markers, each with the same summary.* fields. See Laps.
tags User-defined activity tags.
app_metadata Your app's own JSON on this activity, or null. See App metadata.

Full schema: GET /api/v1/activities/ in the API reference, and the ActivitySummary, ActivitySummarySummary and Lap schemas.

Run it live: open the API playground. It uses your signed-in SweatStack session, so you need no token.

Activity data

GET /api/v1/activities/{activity_id}/data returns the timeseries of one activity as Parquet.

AISC downsampling

The segmentation_on parameter below enables the Adaptive Intensity Segmentation Codec (AISC): payloads 20 to 50 times smaller, with the mean-max curve and the intensity distribution preserved within a bounded error. See it in action

data = client.activities.data(activity_id)

activities.data returns a DataFrame. Pass segmentation_on to downsample with AISC, keyed on "power" or "speed" (the SDK argument keeps the codec's earlier name):

data = client.activities.data(activity_id, segmentation_on="power")

The DataFrame has a timezone-aware UTC timestamp column and a timestamp_local column that holds the naive local wall-clock of each sample. This matches the longitudinal shape: merge and sort on timestamp, group by local calendar date on timestamp_local. See Timezones.

curl -X GET "https://app.sweatstack.no/api/v1/activities/{activity_id}/data" \
    -H "Authorization: Bearer {your_access_token}"

Choose the metrics and enable AISC:

curl -X GET "https://app.sweatstack.no/api/v1/activities/{activity_id}/data?metrics=duration,power,speed,heart_rate&segmentation_on=power" \
    -H "Authorization: Bearer {your_access_token}"

Query parameters:

  • metrics (optional, default duration,power,speed,heart_rate): a comma-separated list of the metrics to include. duration is always included.
  • segmentation_on (optional): downsample with AISC, keyed on power or speed. Omit it for the full timeseries.

Requires the data:read scope.

Reading the Parquet outside the Python SDK

The endpoint returns a ZSTD-compressed Parquet body. If you don't use the Python SDK, read it with any Parquet library that supports the ZSTD codec.

import pandas as pd

# activity.parquet is the response body from GET /activities/{id}/data
df = pd.read_parquet("activity.parquet")  # pyarrow reads ZSTD by default
import { parquetReadObjects } from "hyparquet";
import { compressors } from "hyparquet-compressors"; // adds ZSTD support

const buffer = await fetch(dataUrl, {
  headers: { Authorization: `Bearer ${accessToken}` },
}).then((r) => r.arrayBuffer());

const rows = await parquetReadObjects({ file: buffer, compressors });

hyparquet reads Parquet in Node and in the browser. The companion hyparquet-compressors package supplies the ZSTD codec. See the hyparquet docs for the full API.

Downloading the original file

Sometimes a field that exists in the source recording is missing from the normalized activity data. For that case, GET /api/v1/activities/{activity_id}/download returns the original file from the provider (for example the FIT file), so you can read the raw field yourself.

Treat it as a fallback for that gap, not as your primary data path. For almost everything, the normalized metadata and timeseries endpoints are what you want.

Longitudinal data

GET /api/v1/activities/longitudinal-data returns the timeseries of many activities as one Parquet file.

data = client.activities.longitudinal.data(
    sport=["running"],
    start=start_date,
)

activities.longitudinal.data returns a DataFrame. Pass segmentation_on to downsample with AISC, keyed on "power" or "speed":

data = client.activities.longitudinal.data(
    sport=["cycling"],
    start=start_date,
    segmentation_on="power",
)

The DataFrame has a timezone-aware UTC timestamp column, so samples from activities in different timezones sort and merge on one absolute axis. A timestamp_local column holds the naive local wall-clock of each sample, derived from that sample's own activity, so grouping by local calendar day stays correct across DST changes and travel. Merge and sort on timestamp; group by local day on timestamp_local. See Timezones.

curl -X GET "https://app.sweatstack.no/api/v1/activities/longitudinal-data?sport=running&start=2024-01-01" \
    -H "Authorization: Bearer {your_access_token}"

With every parameter:

curl -X GET "https://app.sweatstack.no/api/v1/activities/longitudinal-data?sport=running&sport=cycling&start=2024-01-01&end=2024-12-31&metrics=power,heart_rate&segmentation_on=power" \
    -H "Authorization: Bearer {your_access_token}"

Query parameters:

  • sport (optional): a sport code. Repeat the parameter for more than one sport.
  • start (optional): first day of the range, YYYY-MM-DD. Included. Matches each activity's own local date.
  • end (optional): last day of the range, YYYY-MM-DD. Included.
  • metrics (optional, default duration,power,speed,heart_rate): a comma-separated list of the metrics to include. duration is always included.
  • segmentation_on (optional): downsample with AISC, keyed on power or speed.

Requires the data:read scope.

Mean-max

The mean-max curve (also called the power-duration or speed-duration curve) gives, for every duration, the highest average value of a metric sustained over any window of that length. It is the basis of power-duration profiling. SweatStack serves it for a single activity and as the best across many activities.

Both endpoints return the same shape: one row per duration, no index.

Column Contents
duration The window length
power or speed The best average over a window of that length (W, or m/s)
start UTC timestamp at which that best effort began
activity_id, sport Longitudinal only: the activity that set this duration's best
after With fatigue states only (see below)

Durations. By default you get 19: 1, 2, 5, 10, 20, 30 s; 1, 2, 3, 5, 10, 15, 20, 30 min; 1, 2, 3, 4, 6 h. They are the same durations the intensity-duration model uses, so the raw and the fitted curve line up row for row. Pass durations=all for the full grid (1 s steps to 3 min, then 5, 10, 30 and 60 s steps, about 700 points up to 6 h), or your own comma-separated list in seconds, for example durations=5,60,300,1200. Values between stored points are interpolated. A duration no activity lasted has no row, so a 40-minute ride has no 1 h row.

The curve is not always decreasing. Hard efforts separated by rests can make a longer best average higher than a shorter one. That is real, and the API returns the curve as it is. If you fit a model, make the curve monotone first. The intensity-duration model does this for you.

Per-activity mean-max

GET /api/v1/activities/{activity_id}/mean-max returns the curve of one activity, cut at the activity's length.

from sweatstack import Client

client = Client()

mean_max = client.activities.mean_max(activity_id, metric="power")

activities.mean_max returns a DataFrame with the 19 default durations. The SDK does not take durations yet; call the API directly for the full grid or your own list.

curl -X GET "https://app.sweatstack.no/api/v1/activities/{activity_id}/mean-max?metric=power" \
    -H "Authorization: Bearer {your_access_token}"

Query parameters:

  • metric (required): power or speed.
  • durations (optional, default the 19 durations above): all, or a comma-separated list in seconds.

Requires the data:read scope.

Longitudinal mean-max

GET /api/v1/activities/longitudinal-mean-max takes, for every duration, the best value any activity matching the sport and date range reached, and says which activity it was.

from datetime import date

from sweatstack import Client

client = Client()

mean_max = client.activities.longitudinal.mean_max(
    sport="cycling",
    metric="power",
    start=date(2024, 1, 1),
    end=date(2024, 12, 31),
)

activities.longitudinal.mean_max returns a DataFrame and takes after as a number or a list.

curl -X GET "https://app.sweatstack.no/api/v1/activities/longitudinal-mean-max?sport=cycling&metric=power&start=2024-01-01&end=2024-12-31" \
    -H "Authorization: Bearer {your_access_token}"

Query parameters:

  • sport (optional): a sport code. Repeat the parameter for more than one sport.
  • metric (required): power or speed.
  • start (optional): first day of the range, YYYY-MM-DD. Included.
  • end (optional, default today): last day of the range, YYYY-MM-DD. Included.
  • durations (optional, default the 19 durations above): all, or a comma-separated list in seconds.
  • after (optional): a fatigue state. Repeat the parameter for up to 5 states. See below.

Requires the data:read scope.

Fatigue states (after)

Each after value computes the curve over only the part of each activity after that much accumulated work (kJ for power; meters for speed, experimental), then takes the best across activities. It shows how the athlete's curve holds up under fatigue. Repeat after for more than one state, up to 5. With after, the response gains an after column and the date range is capped at 1 year. A state no activity reaches has no rows.

These curves are computed from each activity's adaptive segments, which are exact from 1 to 20 s and within about 2 % beyond that, so after=0 can differ slightly from the curve without after.

curl -X GET "https://app.sweatstack.no/api/v1/activities/longitudinal-mean-max?sport=cycling&metric=power&start=2024-01-01&end=2024-12-31&after=0&after=50&after=100" \
    -H "Authorization: Bearer {your_access_token}"

This is the raw curve. For a fitted model on top of it, or for the thresholds most apps want, see Metabolic profile.

Uploading files

POST /api/v1/activities/upload takes up to 10 activity files in one multipart/form-data request. Supported formats: .fit (carries its own sport) and .csv (see CSV format).

curl -X POST "https://app.sweatstack.no/api/v1/activities/upload" \
    -H "Authorization: Bearer {access_token}" \
    -F "[email protected]" \
    -F "[email protected]"

Requires the data:write scope. Request it during authorization; see scopes.

Read the result per file

The API returns 202 with one source per file. It never returns a bare success that hides a failed file:

{
  "sources": [
    { "id": "src_abc", "type": "fit", "filename": "ride.fit", "status": "processing", "error": null, "activity_ids": [] },
    { "id": "src_def", "type": "csv", "filename": "run.csv",  "status": "failed",     "error": { "code": "csv_missing_sport", "message": "CSV must contain a 'sport' column …" }, "activity_ids": [] }
  ]
}

Each file is independent. A bad file is failed with a named reason; the good files in the same request are processing. If the request itself is wrong (no files, more than 10 files), the API returns an RFC 7807 application/problem+json 4xx instead.

Get the outcome by polling or by webhook

Processing is asynchronous. Learn each source's final outcome in one of two ways:

  • Poll GET /api/v1/sources/{id} until status is processed (with activity_ids filled in) or failed (with error). A source that is still processing after 30 minutes reads as failed with error code processing_timeout. To list and reconcile, call GET /api/v1/sources?status=failed&created_after=…; the total count is in the X-Total-Count header.
  • Webhooks. source_processed and source_failed fire once per upload, and activity_created fires once per resulting activity. Each carries the source id; fetch GET /api/v1/sources/{id} for details.

The source_id on every activity resolves to the same GET /api/v1/sources/{id}, so you can always trace an activity back to its upload.

Error codes

Code Meaning
no_files, too_many_files The request is wrong. Returned as an RFC 7807 4xx.
unsupported_file_type The file is not .fit or .csv.
empty_file, file_too_large The file envelope is wrong.
csv_missing_sport, invalid_sport, csv_missing_timestamp, csv_invalid_timestamps The CSV content is wrong. Caught at upload time. invalid_sport means the sport is not a recognized Open Sport Taxonomy code.
unreadable_file, no_data The file parsed to nothing usable. Reported on the source after processing.

CSV format

A CSV must have a sport column (one Open Sport Taxonomy code, for example cycling.road) and a timestamp column with offset-aware ISO 8601 datetimes (ending in +02:00, Z, or another offset), so the file carries its own sport and timezone. SweatStack keeps the recognized metric columns (power, heart_rate, cadence, altitude, distance, speed, and the others in the data model) and drops every other column. Download a working sample CSV.

Backfill status

When a user first connects their account, SweatStack backfills their historical activities, newest first. GET /api/v1/activities/backfill-status streams the progress, so your app can tell how far back the history reaches. Use it when your app needs a minimum amount of history before it works, for example 4 weeks.

from datetime import datetime, timedelta

from sweatstack import Client

client = Client()

four_weeks_ago = datetime.now() - timedelta(weeks=4)

# Loop over the status, new update every ~3s
for status in client.activities.watch_backfill_status():
    if status.backfill_loaded_until <= four_weeks_ago:
        print("Backfilled at least 4 weeks")
        break

# Or check once
status = client.activities.backfill_status()
print(f"Backfilled to: {status.backfill_loaded_until}")

activities.watch_backfill_status yields a status for every update and stops when the connection closes after 60 seconds. Pass auto_reconnect=True to keep watching. activities.backfill_status returns the first update and closes.

curl -N -X GET "https://app.sweatstack.no/api/v1/activities/backfill-status" \
    -H "Authorization: Bearer {your_access_token}"

-N disables buffering, so you see each update as it arrives.

The API streams newline-delimited JSON (NDJSON):

{"backfill_loaded_until": "2024-03-01T10:00:00Z"}
{"backfill_loaded_until": "2024-02-15T08:30:00Z"}
{"backfill_loaded_until": "2024-01-01T12:00:00Z"}

The stream sends an update about every 3 seconds and the API closes the connection after 60 seconds. Reconnect to keep watching. backfill_loaded_until moves backward in time, because SweatStack backfills from the newest activity to the oldest. If the status request fails, the line carries an error field instead.

Requires the data:read scope.