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:
WALK15_ISSUER=https://api.walk15.ltThe TypeScript examples share this configuration type and error class:
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.
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.
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.
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):
{
"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:
| Field | Why you need it |
|---|---|
grant_id | Path parameter for every data request and the key in webhook payloads. Stable for this user and your company, even across reconnects. |
access_token + expiry | Bearer credential, valid for at most 5 minutes (expires_in). |
refresh_token | Single-use. Every refresh returns a new one that replaces it atomically. |
history_from | Earliest date you can request (the user's authorization day). |
connection_version | Generation of this authorization. Used to ignore stale webhooks. |
scope | Space-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.
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"{
"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 a204. - The user disconnects in Walk15 or deletes their account: data requests return
401, refresh returnsinvalid_grant, and aconnection.revokedwebhook is sent. - Refresh fails: never re-send the same refresh token; ask the user to reconnect (why).
429: waitRetry-Afterseconds and reduce concurrency (Rate limits).
Before going live, run through the integration checklist.