Skip to content
Developers

Walk15 Connect V1

OAuth and PKCE

Authorization code flow with S256 PKCE, confidential client authentication, token rotation and revocation.

Walk15 Connect uses the OAuth 2.0 authorization code grant with mandatory S256 PKCE and confidential client authentication. Users authorize in a browser; everything else happens between your backend and Walk15.

Authorization code flow with PKCE
  1. 1User's browser → Your backendUser clicks “Connect Walk15”
  2. 2Your backendCreate state + PKCE verifier, store server-side
  3. 3Your backend → User's browser303 redirect to /oauth/authorize
  4. 4User's browser → Walk15 ConnectGET /oauth/authorize
  5. 5Walk15 Connect → User's browserWalk15 sign-in (email code) and consent
  6. 6Walk15 Connect → User's browser303 to your callback ?code&state
  7. 7User's browser → Your backendGET /your/callback?code&state
  8. 8Your backend → Walk15 ConnectPOST /oauth/token + code_verifier
  9. 9Walk15 Connect → Your backendgrant_id, tokens, history_from
  10. 10Your backend → Walk15 ConnectGET activity with Bearer token

Supported: response_type=code, the authorization_code and refresh_token grants, PKCE method S256, and client_secret_basic client authentication. Not supported: implicit, password, client-credentials and device grants; OpenID Connect and ID tokens; dynamic client registration; token introspection; incremental consent.

Discovery

Authorization server metadata is published at the issuer root:

Shell
curl "$WALK15_ISSUER/.well-known/oauth-authorization-server"
JSON
{
  "issuer": "https://api.walk15.lt",
  "authorization_endpoint": "https://api.walk15.lt/oauth/authorize",
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint": "https://api.walk15.lt/oauth/token",
  "revocation_endpoint": "https://api.walk15.lt/oauth/revoke",
  "token_endpoint_auth_methods_supported": ["client_secret_basic"],
  "revocation_endpoint_auth_methods_supported": ["client_secret_basic"],
  "scopes_supported": ["daily_steps:read", "cycling_daily:read", "cycling_activities:read"],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"]
}

The issuer is the exact HTTPS origin Walk15 gives you; all paths are rooted there. scopes_supported lists every scope Connect knows — not the scopes your company is allowed to request.

Client authentication

The token, revocation and disconnect endpoints require HTTP Basic authentication with your client ID and secret:

  1. Form-encode (encodeURIComponent) the client ID and the secret separately.
  2. Join them with : and base64-encode the result.
  3. Send exactly one Authorization: Basic <value> header.

Credentials or client assertions in the body or query string, or more than one Authorization header, return 400 invalid_request. A failed client authentication returns 401 invalid_client with a WWW-Authenticate: Basic challenge.

Authorization request

Redirect the user's browser to GET /oauth/authorize (parameters and limits are listed there). Never put a secret, token, verifier or user identifier in this URL.

  • State. A fresh random value per attempt, stored with the initiating user's session and a short expiry, accepted once and only for that same user. Walk15 returns it unchanged.
  • PKCE. A verifier of 43–128 characters from [A-Za-z0-9._~-] that stays on your backend; send code_challenge = BASE64URL(SHA256(verifier)). The PKCE example produces a compliant pair.
  • Redirect URI. Compared with the registered value as an exact string. A registered query string must not contain code, state, error or error_description.
  • Lifetime. Sign-in and consent must complete within 10 minutes. The user signs in again for every new authorization.

Walk15 shows the user:

  1. An entry page naming your company and the requested data.
  2. Walk15 sign-in: the user enters the email of an existing Walk15 account and a one-time code sent to it. No account is created.
  3. The consent screen: your display name and purpose, the signed-in account, every requested dataset, "Access starts on {date} ({timezone})", a statement that permission has no fixed expiry but requires renewal within the inactivity period, your privacy policy link, and Allow access / Cancel. The user can also switch to a different Walk15 account.

Consent is all-or-nothing. If the user's connection with your company has not been disconnected (even if it lapsed or its tokens were revoked) and uses a different set of scopes, Allow is disabled and the user is told to disconnect first — see Changing scopes.

Callback and code exchange

Walk15 redirects to your redirect_uri with ?state=…&code=…, or ?state=…&error=access_denied if the user cancels. Errors found before a valid callback is known — wrong client, redirect mismatch, bad scope or PKCE, expired request, or a consent page left open across the user's local midnight — are shown on a Walk15 error page and not redirected to you.

Exchange the code within 120 seconds (less if the 10-minute authorization request ends sooner) at POST /oauth/token. The code is single-use and bound to your client, callback, PKCE challenge, scopes and the current connection version; any mismatch returns a generic 400 invalid_grant. Store the response as described in Getting started.

Using access tokens

Send exactly one header:

HTTP
GET /connect-walk15/v1/grants/22222222-2222-4222-8222-222222222222/daily-activity?from=2026-10-10&to=2026-10-12 HTTP/1.1
Host: api.walk15.lt
Authorization: Bearer w15a_<access-token>

Access tokens are opaque and last at most 5 minutes (expires_in is the remaining lifetime in seconds). The grant_id in the path must be the one returned with that token, exactly as returned; anything else returns 404. Refresh shortly before expiry rather than waiting for a 401.

Refresh-token rotation

Refresh at POST /oauth/token with grant_type=refresh_token. Each refresh token works once: a successful refresh returns a new access token and a new refresh token, which must replace the old pair atomically.

  • grant_id, scope, history_from and connection_version are unchanged by a refresh.
  • Each successful refresh resets the inactivity deadline: currently 90 days, the same for every customer (the consent screen states the exact number). A connection whose refresh token goes unused for longer requires the user to reconnect, and no webhook announces the lapse. To keep idle connections authorized, refresh them before the period ends — for example after two thirds of it.
  • There is no absolute lifetime: a connection you keep refreshing can stay authorized for years, until the user or Walk15 revokes it, the user deletes their account, or your client is disabled.
  • Refreshing does not move the access start date.

Run at most one refresh per connection at a time across all your instances — for example by holding a row lock while you refresh and save:

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

export type StoredConnection = Walk15Tokens & { accessExpiresAt: number; needsReauthorization: boolean };

export interface ConnectionStore {
  /**
   * Run `fn` while holding an exclusive lock for this grant that is shared by all your
   * instances and authorization callback saves, e.g. SELECT … FOR UPDATE inside one database transaction.
   */
  withGrantLock<T>(grantId: string, fn: (connection: StoredConnection) => Promise<T>): Promise<T>;
  /** Persist inside the same lock/transaction. */
  save(connection: StoredConnection): Promise<void>;
}

const REFRESH_MARGIN_MS = 30_000;

/** Returns a usable access token, refreshing (once, under the lock) when it is about to expire. */
export const getAccessToken = async (config: Walk15Config, store: ConnectionStore, grantId: string): Promise<string> => {
  // Failures are returned, not thrown, inside the lock: a throw would roll back the transaction
  // and lose the needsReauthorization flag, so the next call would replay a consumed token.
  const result = await store.withGrantLock(grantId, async (connection): Promise<{ token: string } | { error: unknown }> => {
    if (connection.needsReauthorization) return { error: new Walk15Error(401, "reauthorization_required") };
    if (connection.accessExpiresAt - Date.now() > REFRESH_MARGIN_MS) return { token: connection.access_token };

    let next: Walk15Tokens;
    try {
      next = await refreshTokens(config, connection.refresh_token);
    } catch (error) {
      // invalid_client: your client credentials are wrong; the refresh token was not used.
      if (error instanceof Walk15Error && error.code === "invalid_client") return { error };
      // invalid_grant, a 5xx or no response at all: the single-use token may already be
      // consumed. Re-sending it would trigger replay protection, so ask the user to reconnect.
      await store.save({ ...connection, needsReauthorization: true });
      return { error };
    }

    if (
      next.grant_id !== connection.grant_id ||
      next.history_from !== connection.history_from ||
      next.connection_version !== connection.connection_version
    ) {
      // Never expected. The old refresh token is now used, so stop refreshing it.
      await store.save({ ...connection, needsReauthorization: true });
      return { error: new Error("Refresh returned tokens for a different connection") };
    }
    const updated: StoredConnection = {
      ...next,
      accessExpiresAt: Date.now() + next.expires_in * 1000,
      needsReauthorization: false,
    };
    await store.save(updated);
    return { token: updated.access_token };
  });
  if ("error" in result) throw result.error;
  return result.token;
};

When refresh fails

ResponseMeaningWhat to do
400 invalid_grantRefresh token expired through inactivity, already used, revoked, or the user/connection is goneMark the connection as needing reauthorization and ask the user to reconnect
401 invalid_clientYour client ID/secret is wrong, replaced or disabledFix your configuration; the refresh token was not consumed
5xx or no responseOutcome unknown — the token may have been consumedDo not resend the same refresh token; treat as reauthorization required

Replay protection

If a refresh token that was already used is presented again by your client, Walk15 assumes it was stolen: it cuts the entire credential family — including any refresh token issued moments earlier by a concurrent request — returns invalid_grant, and (if you use webhooks) sends connection.revoked. The user must authorize again. Credentials from an older connection_version, another customer or malformed values cannot affect the current connection.

This is why refreshes must be serialized and why an uncertain refresh must never be retried blindly.

Revocation

ActionEndpointEffect
Revoke one access tokenPOST /oauth/revoke with an access tokenThat access token stops working
Revoke the credential familyPOST /oauth/revoke with a refresh tokenAll tokens of this connection version stop working; a connection.revoked webhook is sent. The user's consent record remains, so re-authorizing with the same scopes keeps the original history_from.
Disconnect the userDELETE /connect-walk15/v1/customer/grants/{grant_id}Ends the connection completely. Reconnecting later requires fresh consent and starts a new access window on the new authorization day.

Use the disconnect endpoint when a user unlinks Walk15 in your product or closes their account with you. Revocation applies to requests that start afterwards — a read already in progress can still complete — and Walk15 cannot recall data you already received.

Users can also disconnect from Connected Apps in the Walk15 app, and Walk15 revokes connections when a user deletes their account or when a customer is offboarded.

Credential storage

Keep the client secret and all tokens on your backend only, encrypted at rest, and out of logs, error trackers, analytics and support tickets. Never send them or the grant_id to a browser or mobile app; give the app your own session instead. Treat every credential as opaque; the current prefixes help with secret scanning and log redaction:

ValueCurrent prefix
Client secretw15i_
Authorization codew15c_
Access tokenw15a_
Refresh tokenw15r2_
Cycling cursorcc1_