Walk15 Connect V1
Activity data
Forward-only access, calendar dates, steps and cycling semantics, pagination and corrections.
This guide explains what the activity endpoints return and how to keep a correct copy on your side.
Forward-only access
A new connection can read data from the user's authorization calendar day onward — never before it.
- The access start date is returned as
history_fromin every token response and shown to the user on the consent screen. - It is the whole calendar day: activity recorded earlier that day, before the user approved, is included.
- The day is determined in the timezone last stored on the user's Walk15 account (UTC if none is known).
- There is no historical backfill. Requesting any earlier date returns
400 invalid_date_range— ranges are rejected, not trimmed. - Later dates remain available for as long as the connection is authorized.
| Event | history_from |
|---|---|
| Token refresh | Unchanged |
| Re-authorization with the same scopes while still connected | Unchanged |
| Re-authorization after the connection expired through inactivity (not revoked) | Unchanged |
| Disconnect, then reconnect | New — the reconnection day. Earlier dates, including ones you could read before, are no longer accessible. |
Calendar dates
Dates are Walk15's own recorded calendar dates (date_basis: "walk15_recorded_date"), the same days the user sees in the Walk15 app:
- Steps use the date Walk15 recorded for the daily total. Connect does not re-bucket them by timezone.
- Cycling uses the recorded start date of each activity. A ride that crosses midnight counts entirely on its start date.
Request rules for all three endpoints:
fromandtoare required, real calendar dates inYYYY-MM-DDform, withfrom≤to.- At most 31 dates per request (
tomay be at most 30 days afterfrom). frommay not precedehistory_from.tomay not be later than tomorrow in UTC, so users ahead of UTC can be queried for their current local day.
To cover a longer period, request consecutive windows of up to 31 days.
Steps
daily-activity returns one row per date with a Walk15 step record:
stepsis the adjusted Walk15 daily total: the recorded total plus any correction applied by Walk15 support, floored at zero. It is not a raw wearable reading.- A correction can never create a day on its own; a date appears only when Walk15 has a step record for it.
- A row with
steps: 0is an explicit zero. A missing date means no record — not zero.
Cycling totals versus activities
cycling-daily | cycling-activities | |
|---|---|---|
| Granularity | One total per start date | One row per recorded activity |
| Includes Walk15 support adjustments | Yes, added to a day that has at least one activity | No |
| Unit | distance_meters (integer metres) | distance_meters (integer metres) |
| Pagination | No (max 31 rows) | Cursor, up to 500 per page |
Because support adjustments are applied only to daily totals, and because of how individual records were merged historically, the sum of a day's activities can differ from that day's total. Use the endpoint that matches what you display; do not try to reconcile one against the other.
distance_meters is always metres. 18240 means 18.24 km.
Timestamps
| Field | Meaning |
|---|---|
recorded_start_time, recorded_end_time | The clock time Walk15 stored for the activity, YYYY-MM-DDTHH:mm:ss.ffffff, without a timezone. It is not UTC and not guaranteed to be the user's current local time. Do not append Z, convert it, or compute a certified duration from it. recorded_end_time is null when unknown or earlier than the start. |
timezone, manual | Always null in V1 |
granularity | Always unknown |
updated_at | Always null in V1: Walk15 does not expose a reliable "last changed at the source" time |
meta.snapshot_at | When Walk15 generated the response (UTC). It is retrieval time — not when the user's device synced and not when the record last changed. |
Sparse results
All three endpoints are sparse: they return only dates or activities that Walk15 currently has. An empty data array with 200 means "nothing recorded in this range" — it does not certify that the user was inactive, and it is never an error. Errors are always non-2xx responses; never treat an error as an empty or deleted result.
Corrections and deletions
Walk15 data can change after you first read it: late syncs, support corrections, deleted activities, or records moved to another date. Connect has no change feed and no deletion tombstones, so keep your copy correct by replacing snapshots:
- Fetch a date window completely (for activities: every page until
next_cursorisnull). - Only after the whole window succeeded, replace your stored records for exactly those dates in one transaction.
- A date (or activity
id) that was stored before but is missing from the new successful snapshot no longer exists in Walk15 — remove it rather than setting it to zero. - If any page fails, keep your previous data and retry later. A partial fetch never implies deletion.
If you use webhooks, activity.updated tells you which date to re-fetch. If you poll, re-read the recent windows where changes are likely. Both are valid choices.
Pagination
cycling-activities returns at most limit activities (default 100, maximum 500), ordered by recorded start time. When more exist, meta.next_cursor is set:
- Send the cursor unchanged with the same
from,toandlimit. - Cursors are opaque, bound to your connection version and the query, and expire 15 minutes after the first page. Do not store, decode or log them.
- A refresh does not invalidate an in-progress traversal; a new connection version does.
- On
400 invalid_cursor, restart the window from the first page. - Pages reflect live data. Upsert activities by
id, and treat a window as complete only after reachingnext_cursor: null.
TypeScript client
A small client for the three endpoints with cursor traversal and Retry-After handling:
import { Walk15Error, type Walk15Config } from "./walk15-config";
type Meta = { from: string; to: string; date_basis: "walk15_recorded_date"; snapshot_at: string };
export type DailySteps = {
data: { date: string; steps: number; updated_at: null }[];
meta: Meta & { source: "walk15" };
};
export type CyclingDaily = {
data: { date: string; distance_meters: number; source: "walk15"; updated_at: null }[];
meta: Meta;
};
export type CyclingActivity = {
id: string;
date: string;
distance_meters: number;
recorded_start_time: string;
recorded_end_time: string | null;
timezone: null;
manual: null;
granularity: "unknown";
source: "walk15";
updated_at: null;
};
export type CyclingActivities = {
data: CyclingActivity[];
meta: Meta & { time_basis: "walk15_recorded_clock"; next_cursor: string | null };
};
type Responses = { "daily-activity": DailySteps; "cycling-daily": CyclingDaily; "cycling-activities": CyclingActivities };
export type Metric = keyof Responses;
export const getActivity = async <M extends Metric>(
config: Walk15Config,
accessToken: string,
grantId: string,
metric: M,
query: { from: string; to: string; limit?: string; cursor?: string },
): Promise<Responses[M]> => {
const url = new URL(`/connect-walk15/v1/grants/${encodeURIComponent(grantId)}/${metric}`, config.issuer);
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) params.set(key, value);
}
url.search = params.toString();
const response = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
redirect: "error",
signal: AbortSignal.timeout(10_000),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
const retryAfter = Number(response.headers.get("retry-after"));
throw new Walk15Error(response.status, body.error?.code ?? "unknown_error", retryAfter > 0 ? retryAfter : undefined);
}
return body as Responses[M];
};
/** Retries only 429 responses, waiting the advertised Retry-After seconds. */
export const withRateLimitRetry = async <T>(request: () => Promise<T>, maxAttempts = 3): Promise<T> => {
for (let attempt = 1; ; attempt++) {
try {
return await request();
} catch (error) {
if (!(error instanceof Walk15Error) || error.status !== 429 || attempt >= maxAttempts) throw error;
await new Promise((resolve) => setTimeout(resolve, (error.retryAfterSeconds ?? 1) * 1000));
}
}
};
/** Reads every page of a window. `accessToken` is called per page so a refresh can happen in between. */
export const fetchAllCyclingActivities = async (
config: Walk15Config,
accessToken: () => Promise<string>,
grantId: string,
from: string,
to: string,
): Promise<CyclingActivity[]> => {
for (let attempt = 1; ; attempt++) {
const activities: CyclingActivity[] = [];
let cursor: string | null = null;
try {
do {
const page: CyclingActivities = await withRateLimitRetry(async () =>
getActivity(config, await accessToken(), grantId, "cycling-activities", {
from,
to,
limit: "500",
...(cursor ? { cursor } : {}),
}),
);
activities.push(...page.data);
cursor = page.meta.next_cursor;
} while (cursor);
return activities;
} catch (error) {
// The traversal expired (15 minutes) or the connection version changed: restart once.
if (!(error instanceof Walk15Error && error.code === "invalid_cursor" && attempt === 1)) throw error;
}
}
};Combine it with the token manager so accessToken always returns a fresh token:
const rides = await fetchAllCyclingActivities(
config,
() => getAccessToken(config, connectionStore, grantId),
grantId,
"2026-10-10",
"2026-10-16",
);