Skip to content
Developers

Walk15 Connect V1

Getting started

Integrate Walk15 Connect end to end: credentials, authorization, token exchange and your first data request.

This walkthrough takes you from approval to your first activity request. Examples use TypeScript on Node.js 18+ (built-in fetch and node:crypto) and curl. Every identifier below is synthetic and every secret is a placeholder.

1. Get approved and receive credentials

Contact Walk15 through your existing contact or the contact form. After approval, Walk15 sends you a client ID, a client secret and the issuer origin for your environment. Keep the secret in a secret manager; Walk15 cannot show it again, so ask for a replacement if it is lost or exposed.

For curl examples, set the issuer once:

Shell
WALK15_ISSUER=https://api.walk15.lt

The TypeScript examples share this configuration type and error class:

walk15-config.ts
export type Walk15Config = {
  /** Issuer origin provided by Walk15, for example https://api.walk15.lt */
  issuer: string;
  /** Client UUID provided by Walk15. */
  clientId: string;
  /** Backend-only secret. Load it from your secret manager. */
  clientSecret: string;
  /** Must be byte-for-byte identical to the callback registered with Walk15. */
  redirectUri: string;
};

/** Token response returned by POST /oauth/token for both grant types. */
export type Walk15Tokens = {
  grant_id: string;
  history_from: string;
  connection_version: string;
  access_token: string;
  token_type: "Bearer";
  expires_in: number;
  refresh_token: string;
  scope: string;
};

export class Walk15Error extends Error {
  constructor(
    readonly status: number,
    readonly code: string,
    readonly retryAfterSeconds?: number,
  ) {
    super(`Walk15 Connect request failed: ${status} ${code}`);
  }
}

2. Configure your callback URL

Walk15 registers exactly one HTTPS callback for your client, for example https://partner.example/oauth/walk15/callback. The redirect_uri you send must match it byte for byte — no trailing-slash, case, encoding or query-string variations. The callback must:

  • use https:// and a path other than /;
  • contain no fragment, wildcard, credentials, whitespace or backslash;
  • point at your backend. Custom URL schemes and plain-HTTP localhost callbacks are not supported.

3. Create state and a PKCE pair

When a signed-in user clicks "Connect Walk15" in your product, your backend creates a one-time state and a PKCE code_verifier, stores both server-side against that user's session with a short expiry (Walk15's authorization request itself expires after 10 minutes), and derives the S256 code_challenge.

pkce.ts
import { createHash, randomBytes } from "node:crypto";

/** One-time, unguessable value bound to the user's session (Walk15 requires 22–2048 visible ASCII characters). */
export const createState = (): string => randomBytes(32).toString("base64url");

/** RFC 7636 S256: the verifier stays on your backend; only the challenge goes to Walk15. */
export const createPkcePair = (): { verifier: string; challenge: string } => {
  const verifier = randomBytes(32).toString("base64url"); // 43 characters
  const challenge = createHash("sha256").update(verifier).digest("base64url");
  return { verifier, challenge };
};

4. Redirect the user to Walk15

Send the browser (with an HTTP 303 redirect) to the issuer's authorization endpoint. Request only the scopes your product needs.

authorize.ts
import { exchangeCode } from "./token-client";
import type { Walk15Config, Walk15Tokens } from "./walk15-config";

export const buildAuthorizationUrl = (
  config: Walk15Config,
  scopes: readonly string[],
  state: string,
  codeChallenge: string,
): string => {
  const url = new URL("/oauth/authorize", config.issuer);
  url.search = new URLSearchParams({
    client_id: config.clientId,
    redirect_uri: config.redirectUri,
    response_type: "code",
    scope: scopes.join(" "),
    state,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
  }).toString();
  return url.toString();
};

export type PendingAuthorization = { userId: string; codeVerifier: string; expiresAt: number };

export interface AuthorizationStore {
  /** Atomically return AND delete the pending attempt for this state, so it can be used once. */
  takePending(state: string): Promise<PendingAuthorization | undefined>;
  /** Use the same exclusive grant lock as ConnectionStore.withGrantLock, across all instances. */
  withGrantLock<T>(grantId: string, fn: () => Promise<T>): Promise<T>;
  /** Persist inside that lock, encrypted at rest and keyed by your user and the returned grant_id. */
  saveConnection(userId: string, tokens: Walk15Tokens): Promise<void>;
}

/** Handle GET /oauth/walk15/callback for the signed-in user `currentUserId`. */
export const handleWalk15Callback = async (
  config: Walk15Config,
  store: AuthorizationStore,
  currentUserId: string,
  query: URLSearchParams,
): Promise<"connected" | "denied"> => {
  const pending = await store.takePending(query.get("state") ?? "");
  if (!pending || pending.userId !== currentUserId || pending.expiresAt < Date.now()) {
    throw new Error("Unknown, expired or foreign authorization state");
  }
  if (query.get("error") === "access_denied") return "denied";
  const code = query.get("code");
  if (!code) throw new Error("Missing authorization code");
  const tokens = await exchangeCode(config, code, pending.codeVerifier);
  await store.withGrantLock(tokens.grant_id, () => store.saveConnection(pending.userId, tokens));
  return "connected";
};

The user signs in and approves in a normal browser tab — not an iframe or a webview that blocks cookies; nothing needs to be installed. Walk15 then redirects to your callback with code and your unchanged state, or with error=access_denied and state if the user cancelled. The handler above consumes the state and checks it belongs to the same signed-in user before exchanging the code. Invalid requests never reach your callback; the user sees a Walk15 error page instead.

5. Exchange the code

The code is single-use and expires after at most 120 seconds. Exchange it server-to-server with HTTP Basic client authentication and the stored code_verifier.

token-client.ts
import { Walk15Error, type Walk15Config, type Walk15Tokens } from "./walk15-config";

/** HTTP Basic: form-encode the client ID and secret, join with ":", base64-encode. */
export const basicAuthorization = (config: Walk15Config): string =>
  "Basic " +
  Buffer.from(`${encodeURIComponent(config.clientId)}:${encodeURIComponent(config.clientSecret)}`).toString("base64");

const post = async (config: Walk15Config, path: string, fields: Record<string, string>): Promise<Response> =>
  fetch(new URL(path, config.issuer), {
    method: "POST",
    headers: {
      Authorization: basicAuthorization(config),
      "Content-Type": "application/x-www-form-urlencoded",
    },
    body: new URLSearchParams(fields).toString(),
    redirect: "error",
    signal: AbortSignal.timeout(10_000),
  });

const tokenRequest = async (config: Walk15Config, fields: Record<string, string>): Promise<Walk15Tokens> => {
  const response = await post(config, "/oauth/token", fields);
  const body = await response.json().catch(() => ({}));
  if (!response.ok) throw new Walk15Error(response.status, body.error ?? "unknown_error");
  return body as Walk15Tokens;
};

export const exchangeCode = (config: Walk15Config, code: string, codeVerifier: string) =>
  tokenRequest(config, {
    grant_type: "authorization_code",
    code,
    redirect_uri: config.redirectUri,
    code_verifier: codeVerifier,
  });

export const refreshTokens = (config: Walk15Config, refreshToken: string) =>
  tokenRequest(config, { grant_type: "refresh_token", refresh_token: refreshToken });

/** Revokes one access token or a whole refresh-token family. Always 200, even for unknown tokens. */
export const revokeToken = async (
  config: Walk15Config,
  token: string,
  hint: "access_token" | "refresh_token",
): Promise<void> => {
  const response = await post(config, "/oauth/revoke", { token, token_type_hint: hint });
  if (!response.ok) throw new Walk15Error(response.status, "revoke_failed");
};

/** Ends the user's connection for your company. 204 for owned, unknown and already-revoked grants alike. */
export const disconnectGrant = async (config: Walk15Config, grantId: string): Promise<void> => {
  const response = await fetch(
    new URL(`/connect-walk15/v1/customer/grants/${encodeURIComponent(grantId)}`, config.issuer),
    {
      method: "DELETE",
      headers: { Authorization: basicAuthorization(config) },
      redirect: "error",
      signal: AbortSignal.timeout(10_000),
    },
  );
  if (response.status !== 204) throw new Walk15Error(response.status, "disconnect_failed");
};

A successful response (curl version in the API reference):

JSON
{
  "grant_id": "22222222-2222-4222-8222-222222222222",
  "history_from": "2026-10-10",
  "connection_version": "1",
  "access_token": "w15a_<access-token>",
  "token_type": "Bearer",
  "expires_in": 300,
  "refresh_token": "w15r2_<refresh-token>",
  "scope": "daily_steps:read cycling_daily:read"
}

6. Store the credentials securely

Persist, encrypted at rest and keyed to your user:

FieldWhy you need it
grant_idPath parameter for every data request and the key in webhook payloads. Stable for this user and your company, even across reconnects.
access_token + expiryBearer credential, valid for at most 5 minutes (expires_in).
refresh_tokenSingle-use. Every refresh returns a new one that replaces it atomically.
history_fromEarliest date you can request (the user's authorization day).
connection_versionGeneration of this authorization. Used to ignore stale webhooks.
scopeSpace-separated scopes the user approved.

Serialize refreshes per connection across all your instances — see Refresh-token rotation.

7. Fetch authorized activity

Call an activity endpoint with the access token and the stored grant_id. from and to are inclusive dates, at most 31 dates apart and never before history_from.

Shell
curl "$WALK15_ISSUER/connect-walk15/v1/grants/22222222-2222-4222-8222-222222222222/daily-activity?from=2026-10-10&to=2026-10-16" \
  -H "Authorization: Bearer $WALK15_ACCESS_TOKEN"
JSON
{
  "data": [
    { "date": "2026-10-10", "steps": 8412, "updated_at": null },
    { "date": "2026-10-11", "steps": 10377, "updated_at": null }
  ],
  "meta": {
    "from": "2026-10-10",
    "to": "2026-10-16",
    "date_basis": "walk15_recorded_date",
    "source": "walk15",
    "snapshot_at": "2026-10-16T08:30:00.000Z"
  }
}

Dates without a record are absent — not zero. A TypeScript client with pagination and Retry-After handling is in Activity data.

8. Optionally configure webhooks

Ask Walk15 to configure your HTTPS webhook URL; the signing secret is shared with you once, securely. Then build the receiver described in Webhooks.

9. Handle disconnects and errors

  • Your user disconnects in your product: call DELETE /connect-walk15/v1/customer/grants/{grant_id} and remove the local mapping after a 204.
  • The user disconnects in Walk15 or deletes their account: data requests return 401, refresh returns invalid_grant, and a connection.revoked webhook is sent.
  • Refresh fails: never re-send the same refresh token; ask the user to reconnect (why).
  • 429: wait Retry-After seconds and reduce concurrency (Rate limits).

Before going live, run through the integration checklist.