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.
- 1Walk15 Connect → Your receiverPOST signed event
- 2Your receiverVerify HMAC + timestamp, validate schema
- 3Your receiverInsert into inbox, unique on event id
- 4Your receiver → Walk15 Connect2xx within 5 s, tiny body
- 5Your receiver → Your workerInbox row picked up later
- 6Your worker → Walk15 ConnectGET activity for the event dates
- 7Walk15 Connect → Your workerCurrent snapshot
- 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
| Event | Sent when | data fields |
|---|---|---|
connection.authorized | A user approves your consent screen — a new connection, a reconnection, or a same-scope re-authorization | grant_id, connection_version, scopes |
connection.revoked | The 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 detected | grant_id, connection_version |
activity.updated | Steps or cycling data for one date was created, corrected or deleted | grant_id, connection_version, scopes, activity_types, from, to |
Details that matter when processing:
connection.authorizeddoes 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 noconnection.revokedfor the old one.connection.revokedcarries theconnection_versionthat ended. It is not sent when a connection lapses through refresh inactivity.activity.updatedis sent only for active connections whose scopes cover the change (steps→daily_steps:read;cycling→cycling_daily:readand/orcycling_activities:read), for dates on or afterhistory_from.scopeslists the relevant connection scopes. In V1,fromandtoare 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_fromafter every exchange.
Payload
Every event is a JSON object with this envelope:
| Field | Type | Description |
|---|---|---|
version | integer | Payload version, currently 1 |
id | string (UUID) | Event ID — stable across retries. Use it for deduplication. |
type | string | connection.authorized, connection.revoked or activity.updated |
occurred_at | string | When Walk15 recorded the change (UTC, milliseconds). Not the time of the activity. |
data.grant_id | string (UUID) | The connection, as returned in your token response |
data.connection_version | string | Decimal connection version the event belongs to |
data.scopes | string[] | Relevant scopes (absent on connection.revoked) |
data.activity_types | string[] | ["steps"] or ["cycling"] (activity events only) |
data.from, data.to | string | Affected calendar date range, inclusive (activity events only) |
{
"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"
}
}{
"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"]
}
}{
"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:
| Header | Example | Description |
|---|---|---|
Content-Type | application/json | |
Walk15-Timestamp | 1791710487 | Unix time in seconds when this attempt was signed |
Walk15-Event-Id | 33333333-3333-4333-a333-333333333333 | Equals the body's id |
Walk15-Delivery-Id | 77777777-7777-4777-8777-777777777777 | Identifies this delivery; stable across its retries |
Walk15-Key-Version | 1 | Version of the signing secret used |
Walk15-Signature | v1=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:
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:
- Read the raw body bytes before any JSON parsing — re-serialized JSON will not match.
- Pick the secret for
Walk15-Key-Version. - Recompute the signature and compare it in constant time.
- Reject timestamps more than 300 seconds away from your clock. Retries are re-signed with a fresh timestamp, so a valid retry always passes.
- Validate the JSON schema and check that
idequalsWalk15-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.
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.
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_versionwith 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
| Property | Behaviour |
|---|---|
| Guarantee | At least once, no exactly-once. Deduplicate by id. |
| Ordering | None. Events — even for one connection — can arrive in any order. |
| Timeout | 5 seconds per attempt; respond well within it |
| Success | Any 2xx status with a body up to 8 KB |
| Retried | Network and DNS errors, timeouts, 408, 425, 429 and 5xx |
| Final failure | Every other status (including 3xx — redirects are not followed), any response body over 8 KB, or a URL that resolves to a non-public address |
| Attempts | Up to 8, with exponential backoff from 5 seconds (×2 each time, ±20% jitter). Retry-After is ignored. |
| Concurrency | At 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:
- The first change to the adjusted step total produces an event that is eligible immediately.
- Further changes during the following 5 minutes produce one additional event at the end of that window. More changes cannot push it further out.
- A change that leaves the adjusted total identical produces no event.
- 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:
- Agree a rotation time with Walk15 and receive the new secret securely.
- Accept both versions in
getSecretduring the changeover (select byWalk15-Key-Version). - Return
503for a key version you do not know yet, so those deliveries are retried. - 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.