Skip to content

Timezones

SweatStack stores every timestamp as an absolute instant in UTC, paired with the local UTC offset that was in effect where the data was recorded. Reads give you both representations. Writes require you to supply the offset. There is no separate "account timezone" and no IANA zone name anywhere in the API. The offset travels with each record.

This keeps two questions separate:

  • When did it happen, on one global clock? Use the UTC instant. It sorts and compares correctly across athletes and across travel.
  • What did the clock on the wall say? Use the local companion. That is what you show a user and what you bucket by calendar day.

The two representations

Datetime fields come in pairs. The absolute instant and its local wall-clock companion:

Field Meaning Format
start, end, timestamp Absolute UTC instant ISO 8601 with a Z suffix, for example 2026-05-18T15:32:00Z
start_local, end_local, timestamp_local Naive local wall-clock ISO 8601 with no offset, for example 2026-05-18T17:32:00

The local companion is always present and never null. It is the instant with the record's offset already applied, so you never do timezone math to display it.

{
  "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"
}

This activity was recorded at +02:00. The UTC instant is 15:32:00Z, the athlete's clock read 17:32:00. The difference between the two is the offset.

Reading timestamps

Use start (and end, timestamp) whenever you need a correct absolute ordering: sorting activities, comparing against a deadline, storing a canonical instant, merging timeseries from different sources.

Use start_local (and end_local, timestamp_local) for anything a human sees, and for grouping by calendar day. "Did I train today?" and "how much did I ride this week?" are local-calendar questions. Answer them with the local fields, not by converting UTC yourself.

start and end are timezone-aware UTC datetime objects. start_local and end_local are naive datetime objects holding the local wall-clock.

from sweatstack import Client

client = Client()

activity = client.activities.latest()

activity.start        # 2026-05-18 15:32:00+00:00  (aware, UTC)
activity.start_local  # 2026-05-18 17:32:00         (naive, local wall-clock)

In a DataFrame from activities.list(output="pandas"), start and end are a single datetime64[ns, UTC] column, and start_local / end_local are naive-local columns. Bucket by local day on the local column:

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

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

The response carries both start (UTC, Z) and start_local (naive local).

Writing timestamps

When you create or update a trace or a test, the timestamp you send must carry an offset. If you send a naive datetime (no offset), the API returns 422 Unprocessable Entity. SweatStack stores the instant plus the offset you supplied, and returns both representations on every later read.

The API accepts an explicit offset or a Z suffix. Send the offset of the place where the session took place, so the stored local wall-clock is correct.

Scheduled workouts are the exception: a workout is a plan, not a recording, so its start is the athlete's naive local wall-clock (no offset). See Workouts.

Attach a zone to the datetime. Use zoneinfo for a real location (it handles DST for you), or timezone.utc when the instant is already in UTC.

from datetime import datetime
from zoneinfo import ZoneInfo

from sweatstack import Client

client = Client()

client.tests.create(
    sport="cycling.road",
    start=datetime(2026, 5, 6, 10, 0, tzinfo=ZoneInfo("Europe/Amsterdam")),
)

A naive datetime(2026, 5, 6, 10, 0) 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 '{
        "sport": "cycling.road",
        "start": "2026-05-06T10:00:00+02:00"
    }'

"2026-05-06T10:00:00" (no offset) returns 422.

Timeseries data

The timeseries DataFrames (activities.data for one activity and activities.longitudinal.data across many) have a timezone-aware UTC timestamp column, so samples from activities in different timezones share one absolute axis and merge and sort correctly. A timestamp_local column carries the naive local wall-clock of each sample, derived from that sample's own activity offset, so it stays correct across DST changes and travel within a single query.

The rule of thumb:

  • Merge and sort on timestamp.
  • Group and bucket by local calendar day on timestamp_local.
from datetime import date, timedelta

from sweatstack import Client

client = Client()

data = client.activities.longitudinal.data(
    sport=["cycling.road"],
    start=date.today() - timedelta(days=90),
)

# Total work per local training day
data.groupby(data["timestamp_local"].dt.date)["power"].sum()

Date-range filtering

The list endpoints (GET /activities/, /tests/, /traces/) filter by date range on each record's own local date. Both start and end are included. A range like start=2026-06-01&end=2026-06-30 returns exactly the records whose local start falls in June, wherever the athlete was. Pass dates as YYYY-MM-DD.

curl -X GET "https://app.sweatstack.no/api/v1/activities/?start=2026-06-01&end=2026-06-30" \
    -H "Authorization: Bearer {your_access_token}"

The longitudinal-data endpoint takes start and end as dates as well.

Why no timezone name

SweatStack models timezones as offsets, not as IANA zone names (Europe/Amsterdam), because the upstream sources (device files, integrations) reliably provide an offset and do not reliably provide a zone. An offset is enough to reconstruct the local wall-clock, which is what the *_local fields give you. If you need a named zone for display, resolve it on your side from the athlete's known location. The API does not guess one for you.