Skip to content
Developers

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_from in 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.
Eventhistory_from
Token refreshUnchanged
Re-authorization with the same scopes while still connectedUnchanged
Re-authorization after the connection expired through inactivity (not revoked)Unchanged
Disconnect, then reconnectNew — 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:

  • from and to are required, real calendar dates in YYYY-MM-DD form, with from ≤ to.
  • At most 31 dates per request (to may be at most 30 days after from).
  • from may not precede history_from.
  • to may 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:

  • steps is 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: 0 is an explicit zero. A missing date means no record — not zero.

Cycling totals versus activities

cycling-dailycycling-activities
GranularityOne total per start dateOne row per recorded activity
Includes Walk15 support adjustmentsYes, added to a day that has at least one activityNo
Unitdistance_meters (integer metres)distance_meters (integer metres)
PaginationNo (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

FieldMeaning
recorded_start_time, recorded_end_timeThe 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, manualAlways null in V1
granularityAlways unknown
updated_atAlways null in V1: Walk15 does not expose a reliable "last changed at the source" time
meta.snapshot_atWhen 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:

  1. Fetch a date window completely (for activities: every page until next_cursor is null).
  2. Only after the whole window succeeded, replace your stored records for exactly those dates in one transaction.
  3. 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.
  4. 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, to and limit.
  • 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 reaching next_cursor: null.

TypeScript client

A small client for the three endpoints with cursor traversal and Retry-After handling:

activity-client.ts
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:

TypeScript
const rides = await fetchAllCyclingActivities(
  config,
  () => getAccessToken(config, connectionStore, grantId),
  grantId,
  "2026-10-10",
  "2026-10-16",
);