Skip to content
Developers

Walk15 Connect V1

Webhooks

Signed, at-least-once notifications for connection and activity changes, and how to process them safely.

Webhooks tell your backend that something changed for a connection, so you can fetch fresh data without polling on a tight schedule. They are optional: everything also works with polling alone.

Recommended webhook handling
  1. 1Walk15 Connect → Your receiverPOST signed event
  2. 2Your receiverVerify HMAC + timestamp, validate schema
  3. 3Your receiverInsert into inbox, unique on event id
  4. 4Your receiver → Walk15 Connect2xx within 5 s, tiny body
  5. 5Your receiver → Your workerInbox row picked up later
  6. 6Your worker → Walk15 ConnectGET activity for the event dates
  7. 7Walk15 Connect → Your workerCurrent snapshot
  8. 8Your workerReplace stored records for those dates

Setup

Webhooks are configured by Walk15 during onboarding or on request. Provide one endpoint URL that:

  • uses https:// with a certificate that passes normal TLS validation;
  • resolves only to public IP addresses (private, loopback and reserved ranges are refused);
  • has no query string, fragment or embedded credentials;
  • answers directly — redirects are not followed.

Walk15 generates a signing secret unique to your company and shares it with you once through a secure channel. Use the secret string exactly as issued as the HMAC key (its UTF-8 bytes; do not base64-decode it). Every delivery states which secret version signed it in the Walk15-Key-Version header.

Event types

EventSent whendata fields
connection.authorizedA user approves your consent screen — a new connection, a reconnection, or a same-scope re-authorizationgrant_id, connection_version, scopes
connection.revokedThe connection ends: the user disconnects in Walk15, you call the disconnect endpoint, the user deletes their Walk15 account, Walk15 disables your client, you revoke a refresh token, or refresh-token replay is detectedgrant_id, connection_version
activity.updatedSteps or cycling data for one date was created, corrected or deletedgrant_id, connection_version, scopes, activity_types, from, to

Details that matter when processing:

  • connection.authorized does not contain tokens and can arrive before or after your callback finishes the code exchange. A same-scope re-authorization sends it with the new version and no connection.revoked for the old one.
  • connection.revoked carries the connection_version that ended. It is not sent when a connection lapses through refresh inactivity.
  • activity.updated is sent only for active connections whose scopes cover the change (steps → daily_steps:read; cycling → cycling_daily:read and/or cycling_activities:read), for dates on or after history_from. scopes lists the relevant connection scopes. In V1, from and to are the same single date.
  • Activity notifications start only once you have exchanged the code for the current connection version. Changes between the user's approval and your exchange are not notified, so read from history_from after every exchange.

Payload

Every event is a JSON object with this envelope:

FieldTypeDescription
versionintegerPayload version, currently 1
idstring (UUID)Event ID — stable across retries. Use it for deduplication.
typestringconnection.authorized, connection.revoked or activity.updated
occurred_atstringWhen Walk15 recorded the change (UTC, milliseconds). Not the time of the activity.
data.grant_idstring (UUID)The connection, as returned in your token response
data.connection_versionstringDecimal connection version the event belongs to
data.scopesstring[]Relevant scopes (absent on connection.revoked)
data.activity_typesstring[]["steps"] or ["cycling"] (activity events only)
data.from, data.tostringAffected calendar date range, inclusive (activity events only)
JSON
{
  "version": 1,
  "id": "33333333-3333-4333-a333-333333333333",
  "type": "activity.updated",
  "occurred_at": "2026-10-11T08:41:27.115Z",
  "data": {
    "grant_id": "22222222-2222-4222-8222-222222222222",
    "connection_version": "1",
    "scopes": ["daily_steps:read"],
    "activity_types": ["steps"],
    "from": "2026-10-11",
    "to": "2026-10-11"
  }
}
JSON
{
  "version": 1,
  "id": "55555555-5555-4555-a555-555555555555",
  "type": "connection.authorized",
  "occurred_at": "2026-10-10T09:15:02.481Z",
  "data": {
    "grant_id": "22222222-2222-4222-8222-222222222222",
    "connection_version": "1",
    "scopes": ["daily_steps:read", "cycling_daily:read"]
  }
}
JSON
{
  "version": 1,
  "id": "66666666-6666-4666-a666-666666666666",
  "type": "connection.revoked",
  "occurred_at": "2026-11-02T17:20:45.009Z",
  "data": {
    "grant_id": "22222222-2222-4222-8222-222222222222",
    "connection_version": "1"
  }
}

Signature verification

Each delivery is an HTTPS POST with these headers:

HeaderExampleDescription
Content-Typeapplication/json
Walk15-Timestamp1791710487Unix time in seconds when this attempt was signed
Walk15-Event-Id33333333-3333-4333-a333-333333333333Equals the body's id
Walk15-Delivery-Id77777777-7777-4777-8777-777777777777Identifies this delivery; stable across its retries
Walk15-Key-Version1Version of the signing secret used
Walk15-Signaturev1=5d7c… (64 hex characters)HMAC-SHA256 signature

The signature is computed over the timestamp, event ID, delivery ID and the exact raw request body, separated by newlines:

Text
signed_payload = Walk15-Timestamp + "\n" + Walk15-Event-Id + "\n" + Walk15-Delivery-Id + "\n" + raw_body
Walk15-Signature = "v1=" + lowercase_hex(HMAC_SHA256(signing_secret, signed_payload))

To verify a delivery:

  1. Read the raw body bytes before any JSON parsing — re-serialized JSON will not match.
  2. Pick the secret for Walk15-Key-Version.
  3. Recompute the signature and compare it in constant time.
  4. Reject timestamps more than 300 seconds away from your clock. Retries are re-signed with a fresh timestamp, so a valid retry always passes.
  5. Validate the JSON schema and check that id equals Walk15-Event-Id.

Receiver example

A dependency-free Node.js receiver. It verifies, validates, stores the event idempotently and acknowledges — nothing else. If you use Express, mount the route with express.raw({ type: "application/json" }) so the body stays raw.

webhook-receiver.ts
import { createHmac, timingSafeEqual } from "node:crypto";
import { createServer, type IncomingHttpHeaders } from "node:http";

export type Walk15Event = {
  version: 1;
  id: string;
  type: "connection.authorized" | "connection.revoked" | "activity.updated";
  occurred_at: string;
  data: {
    grant_id: string;
    connection_version: string;
    scopes?: string[];
    activity_types?: ("steps" | "cycling")[];
    from?: string;
    to?: string;
  };
};

export interface Inbox {
  /** Durably store the event; a duplicate id must be a no-op (INSERT … ON CONFLICT DO NOTHING). */
  insertOnce(event: Walk15Event): Promise<void>;
}

const header = (headers: IncomingHttpHeaders, name: string): string | undefined => {
  const value = headers[name];
  return typeof value === "string" ? value : undefined;
};

export const verifyWalk15Signature = (
  secret: string,
  headers: IncomingHttpHeaders,
  rawBody: Buffer,
  nowMs: number = Date.now(),
): boolean => {
  const timestamp = header(headers, "walk15-timestamp");
  const eventId = header(headers, "walk15-event-id");
  const deliveryId = header(headers, "walk15-delivery-id");
  const signature = header(headers, "walk15-signature");
  if (!timestamp || !eventId || !deliveryId || !signature || !/^\d{10}$/.test(timestamp)) return false;
  if (Math.abs(nowMs / 1000 - Number(timestamp)) > 300) return false;
  const expected =
    "v1=" +
    createHmac("sha256", secret).update(`${timestamp}\n${eventId}\n${deliveryId}\n`).update(rawBody).digest("hex");
  const actual = Buffer.from(signature);
  return actual.length === expected.length && timingSafeEqual(actual, Buffer.from(expected));
};

const EVENT_TYPES = ["connection.authorized", "connection.revoked", "activity.updated"];
const DATE = /^\d{4}-\d{2}-\d{2}$/;

export const parseWalk15Event = (rawBody: Buffer, eventIdHeader: string | undefined): Walk15Event | null => {
  try {
    const event = JSON.parse(rawBody.toString("utf8"));
    const data = event?.data;
    if (event?.version !== 1 || event.id !== eventIdHeader || !EVENT_TYPES.includes(event.type)) return null;
    if (typeof data?.grant_id !== "string" || !/^[1-9]\d*$/.test(String(data.connection_version))) return null;
    if (event.type === "activity.updated" && !(DATE.test(data.from) && DATE.test(data.to) && Array.isArray(data.scopes)))
      return null;
    return event as Walk15Event;
  } catch {
    return null;
  }
};

/** `getSecret` maps Walk15-Key-Version to a signing secret you hold. */
export const createWalk15WebhookServer = (getSecret: (keyVersion: string) => string | undefined, inbox: Inbox) =>
  createServer(async (request, response) => {
    if (request.method !== "POST" || request.url !== "/webhooks/walk15") return void response.writeHead(404).end();

    const chunks: Buffer[] = [];
    let size = 0;
    for await (const chunk of request) {
      size += chunk.length;
      if (size > 65_536) return void response.writeHead(413).end();
      chunks.push(chunk);
    }
    const rawBody = Buffer.concat(chunks);

    const secret = getSecret(header(request.headers, "walk15-key-version") ?? "");
    // Unknown key version (mid-rotation): 503 makes Walk15 retry after you install the new secret.
    if (!secret) return void response.writeHead(503).end();
    if (!verifyWalk15Signature(secret, request.headers, rawBody)) return void response.writeHead(401).end();

    const event = parseWalk15Event(rawBody, header(request.headers, "walk15-event-id"));
    if (!event) return void response.writeHead(400).end();

    try {
      await inbox.insertOnce(event);
    } catch {
      return void response.writeHead(503).end(); // not stored: let Walk15 retry
    }
    // Acknowledge now. Fetching data happens later, outside this request.
    response.writeHead(204).end();
  });

Processing events

Process inbox rows asynchronously with a bounded worker pool, independent of the HTTP request that delivered them. Each event is a hint: decide whether it still applies, then fetch the authoritative snapshot.

webhook-worker.ts
import { fetchAllCyclingActivities, getActivity, withRateLimitRetry } from "./activity-client";
import { getAccessToken, type ConnectionStore, type StoredConnection } from "./token-manager";
import { Walk15Error, type Walk15Config } from "./walk15-config";
import type { Walk15Event } from "./webhook-receiver";

export type Dataset = "steps" | "cycling_daily" | "cycling_activities";

export interface SnapshotStore {
  findConnection(grantId: string): Promise<StoredConnection | undefined>;
  /** Drop tokens and mark the connection ended — only if it is still on this version. */
  endConnection(grantId: string, connectionVersion: string): Promise<void>;
  /** Replace all stored records of a dataset for from..to (inclusive) in one transaction. */
  replace(grantId: string, dataset: Dataset, from: string, to: string, records: unknown[]): Promise<void>;
}

/** "retry_later" keeps the inbox row; expire such rows after a reasonable time (e.g. 24 hours). */
export const processWalk15Event = async (
  config: Walk15Config,
  connections: ConnectionStore,
  snapshots: SnapshotStore,
  event: Walk15Event,
): Promise<"done" | "retry_later"> => {
  const grantId = event.data.grant_id;
  const connection = await snapshots.findConnection(grantId);
  if (!connection) return "retry_later"; // your callback may not have stored the tokens yet

  const eventVersion = BigInt(event.data.connection_version);
  const storedVersion = BigInt(connection.connection_version);
  if (eventVersion < storedVersion) return "done"; // about an older authorization: ignore
  if (eventVersion > storedVersion) return "retry_later"; // newer authorization not exchanged yet

  if (event.type === "connection.revoked") {
    await snapshots.endConnection(grantId, event.data.connection_version);
    return "done";
  }
  if (event.type !== "activity.updated" || !event.data.from || !event.data.to) return "done";

  const from = event.data.from < connection.history_from ? connection.history_from : event.data.from;
  const to = event.data.to;
  const granted = connection.scope.split(" ");
  const scopes = (event.data.scopes ?? []).filter((scope) => granted.includes(scope));
  const token = () => getAccessToken(config, connections, grantId);

  try {
    // Fetch everything first; a failed read must never publish a partial snapshot.
    const staged: [Dataset, unknown[]][] = [];
    for (const scope of scopes) {
      if (scope === "daily_steps:read") {
        const steps = await withRateLimitRetry(async () =>
          getActivity(config, await token(), grantId, "daily-activity", { from, to }),
        );
        staged.push(["steps", steps.data]);
      } else if (scope === "cycling_daily:read") {
        const cycling = await withRateLimitRetry(async () =>
          getActivity(config, await token(), grantId, "cycling-daily", { from, to }),
        );
        staged.push(["cycling_daily", cycling.data]);
      } else if (scope === "cycling_activities:read") {
        staged.push(["cycling_activities", await fetchAllCyclingActivities(config, token, grantId, from, to)]);
      }
    }
    for (const [dataset, records] of staged) await snapshots.replace(grantId, dataset, from, to, records);
    return "done";
  } catch (error) {
    // The user must reconnect before anything can be fetched. Everything else — including
    // invalid_client and a rejected access token — keeps the inbox row and retries with backoff.
    if (error instanceof Walk15Error && error.code === "reauthorization_required") return "done";
    throw error;
  }
};

Rules the worker follows:

  • Version first. Compare connection_version with the version stored alongside your tokens. Older → ignore. Newer → wait until your code exchange stores it. Equal → act.
  • Revocation. Drop tokens and stop reading for that version. Do not delete a newer connection because a delayed revocation for an older version arrived, and ignore later events for a version you have already ended — a delivery already in flight can arrive after its connection.revoked.
  • Re-fetch, don't apply. Two events for the same date are fine: each fetch returns the current state and the last successful replacement wins.
  • Respect limits. Webhook-driven reads share your rate policy with all other reads. Bound concurrency and honor Retry-After.

Delivery semantics

PropertyBehaviour
GuaranteeAt least once, no exactly-once. Deduplicate by id.
OrderingNone. Events — even for one connection — can arrive in any order.
Timeout5 seconds per attempt; respond well within it
SuccessAny 2xx status with a body up to 8 KB
RetriedNetwork and DNS errors, timeouts, 408, 425, 429 and 5xx
Final failureEvery other status (including 3xx — redirects are not followed), any response body over 8 KB, or a URL that resolves to a non-public address
AttemptsUp to 8, with exponential backoff from 5 seconds (×2 each time, ±20% jitter). Retry-After is ignored.
ConcurrencyAt most 2 deliveries in flight to your endpoint at a time

Retries for one event span roughly 10 minutes. After the last attempt, or after a final failure, the delivery becomes a dead letter that Walk15 can redeliver for 30 days if you quote its Walk15-Delivery-Id; the redelivery keeps the same IDs. You can always re-fetch the affected dates yourself.

Before every attempt Walk15 re-checks the connection. Pending connection.authorized and activity.updated events are dropped if the connection was revoked, lapsed (activity events only) or its version or scopes changed; connection.revoked is still delivered. Any change to your webhook configuration — a new URL, or disabling and re-enabling webhooks — drops every queued event, including connection.revoked, and no events are created while webhooks are disabled.

Step notification coalescing

Step totals can change many times while a device syncs. For each connection and date:

  1. The first change to the adjusted step total produces an event that is eligible immediately.
  2. Further changes during the following 5 minutes produce one additional event at the end of that window. More changes cannot push it further out.
  3. A change that leaves the adjusted total identical produces no event.
  4. A change after the window starts a new cycle.

Cycling and connection events are not coalesced. The 5-minute window is a batching interval, not a delivery-time guarantee: queueing, retries and your own rate limits add to it.

Secret rotation

Walk15 rotates your signing secret on request (for example if it may have leaked). Rotation is immediate: from that moment every attempt — including retries of older events — is signed with the new secret, and Walk15-Key-Version increases. There is no overlap period on Walk15's side, so:

  1. Agree a rotation time with Walk15 and receive the new secret securely.
  2. Accept both versions in getSecret during the changeover (select by Walk15-Key-Version).
  3. Return 503 for a key version you do not know yet, so those deliveries are retried.
  4. Remove the old secret once deliveries arrive with the new version.

Changing your webhook URL also issues a new signing secret with a higher Walk15-Key-Version, and drops queued events (see Delivery semantics).

Webhooks and polling

Webhooks are hints, not a guarantee: delivery can be delayed or dropped, and Walk15 records changes on a best-effort basis, so not every change is guaranteed to produce a notification. If you need a particular freshness, also re-read recent dates on your own schedule.