Recipe: Anchor and Token Bookkeeping for Incremental Sync
Last verified October 2, 2026 · 19 min read
Covered here:Health ConnectHealthKit
A change cursor is only as trustworthy as the moment you save it. Save it before the batch it covers is durable and a crash skips that batch forever, with nothing downstream able to tell. Save it after, and the price of a crash is a replay — free if applying is idempotent, a doubled day if it is not. Incremental sync explains what HealthKit's anchors and Health Connect's tokens are, and why the change feed is a detector of dirty days rather than a data channel. This page is the bookkeeping between the platform call and your database.
What the platforms document, and what they leave to you#
Apple's reference says an HKAnchoredObjectQuery "returns an anchor value that corresponds to the last sample or deleted object received by that query", and that "subsequent queries can use this anchor to restrict their results to only newer saved or deleted objects." HKQueryAnchor conforms to NSSecureCoding, so it can be archived between launches; its reference page says nothing about expiry or about validity across a reinstall. Apple also documents that deleted objects "are temporary; the system may remove them from the HealthKit store at any time to free up space" — one reason a periodic re-read with a set-difference is worth having at all.
Google's sync guide states that "an unused Changes token expires within 30 days", and the getChangesToken reference puts it differently: tokens "are only valid for 30 days after they're generated." Either way the signal is the same field. ChangesResponse.changesTokenExpired is documented as "whether requested Changes-Token has expired", and the getChanges reference says that when a token has expired, "ChangesResponse.changesTokenExpired will be set, and clients should generate a new changes-token via getChangesToken." Because the two lifetime phrasings are not the same rule, check the flag on every call rather than computing an expiry date. The guide's own loop ends with "store the next token and reserve it for a future import", and tells you to take only upsertions "that didn't come from the calling app".
What neither platform does is decide when the cursor is saved relative to your write, make your write idempotent, or tell you which days are now wrong. That is this file.
Mapping the adapter onto each platform#
| Recipe input | Health Connect | HealthKit |
|---|---|---|
getChanges(cursor) | getChanges(token); each UpsertionChange becomes an upsert, each DeletionChange a delete by id; nextChangesToken, hasMore and changesTokenExpired map straight across | an HKAnchoredObjectQuery with the stored HKQueryAnchor; samples become upserts keyed on uuid, each HKDeletedObject a delete by uuid, the returned anchor is the next cursor |
fullRead({ since }) | getChangesToken first, then a time-bounded read of the record type — cursorFirstFullRead composes the two in that order | the same query with a nil anchor, which returns the matching data and a fresh anchor together |
record version | metadata.lastModifiedTime | a constant — Apple documents HealthKit objects as immutable, with a few exceptions such as the source revision |
ownOrigin | metadata.dataOrigin.packageName | HKSource.bundleIdentifier |
What the recipe does#
- Saves the cursor last. Per page the order is fetch, apply, save. The apply is one store write covering the whole page and is awaited before the cursor moves, so a crash anywhere replays at most one page.
- Applies idempotently. Upserts are keyed on the platform's record id and gated on a version, so an equal or older version is a no-op. A deletion leaves a tombstone that keeps the record's identity, version and civil day but drops its payload, so a replayed upsert cannot resurrect it.
- Treats expiry as a flag and rejection as a throw, then handles both the same way. An adapter returns
cursorExpired: truefor Health Connect's flag and throwsCursorInvalidErrorfor a cursor that is rejected outright. Either one triggers the fallback; any other error is rethrown with the stored cursor untouched, because a bug is not a reason to re-read someone's history. - Falls back to a bounded, deduped re-read. It reads from your last read or 30 days back, whichever is further — Google's most ideal strategy is to "re-read all data from the most recent timestamp or for the last 30 days" and then dedupe "using identifiers". It takes the new cursor before re-reading, and diffs what it holds in the window against what came back, so a deletion made while the cursor was dead is caught.
- Never reconciles on a first sync. If rows are already held when there is no cursor — a reinstall, a lost cursor store — an initial read that returns less may only mean the readable window moved. Absence there proves nothing, so nothing is tombstoned.
- Outputs dirty days, not totals. Every applied change marks its civil day dirty, an edit that moves a record dirties both days, and a deletion dirties the day stored for that id. A re-read of unchanged data dirties nothing.
The implementation#
The canonical file is cookbook/incremental-sync-anchor.mjs in this site's repository, and cookbook/incremental-sync-anchor.test.mjs runs against it on every CI run. The listing below is a verbatim copy of that file. It has no runtime dependencies, needs Node 20 or newer, and takes its change source, both stores and its clock as arguments, so the whole suite runs with no device and no network.
/**
* incremental-sync-anchor.mjs
*
* WHAT THIS IMPLEMENTS
* The bookkeeping around an opaque change cursor (a HealthKit query anchor,
* a Health Connect changes token, any provider's sync token) that the
* platform call itself does not do for you:
* 1. The cursor is saved ONLY after the batch it covers has been durably
* applied. Per page: fetch, then apply (awaited), then save the
* cursor. A crash anywhere in that sequence replays at most one page.
* 2. Applying is idempotent. Upserts are keyed on the platform's record id
* and gated on a version; a deletion leaves a tombstone that keeps the
* last version, so a replayed page, or the whole history after a lost
* cursor, changes nothing it has already changed.
* 3. An expired or rejected cursor falls back to a bounded full re-read,
* deduped against what you hold by id, plus a set-difference that
* catches deletions made while the cursor was dead.
* 4. What comes out is a list of civil days that are now wrong, never a
* running total to add to. A re-read of unchanged data dirties nothing.
* 5. Anything unrecognised is rethrown with the cursor untouched.
*
* NATIVE APIs AN ADAPTER WRAPS (names checked against the vendors' docs)
* HealthKit: HKAnchoredObjectQuery, HKQueryAnchor, HKDeletedObject.
* https://developer.apple.com/documentation/healthkit/hkanchoredobjectquery
* Health Connect: getChangesToken, getChanges, and ChangesResponse's
* changes, hasMore, nextChangesToken and changesTokenExpired.
* https://developer.android.com/health-and-fitness/health-connect/sync-data
* https://developer.android.com/reference/kotlin/androidx/health/connect/client/response/ChangesResponse
*
* WHICH aifitnessapi.com PAGES DOCUMENT THE PATTERN
* https://aifitnessapi.com/architecture/incremental-sync
* https://aifitnessapi.com/cookbook/incremental-sync-anchor
*
* Node 20+. Zero runtime dependencies. The change source, both stores and the
* clock are injected, so the suite runs with no device, no network and no
* waiting.
*
* MIT — from aifitnessapi.com/cookbook
*/
const MS_PER_DAY = 86_400_000;
/** On token expiry, Google's guide re-reads "for the last 30 days". */
const DEFAULT_RESYNC_LOOKBACK_MS = 30 * MS_PER_DAY;
export const SYNC_MODE = Object.freeze({
/** No cursor yet. HealthKit: a nil anchor. Health Connect: no token. */
INITIAL: "initial",
/** Spent a stored cursor and saved the next one. */
INCREMENTAL: "incremental",
/** The stored cursor was expired or rejected; re-read and reconciled. */
RESYNC: "resync",
});
export const RESYNC_REASON = Object.freeze({
NO_CURSOR: "no_cursor",
CURSOR_EXPIRED: "cursor_expired",
CURSOR_INVALID: "cursor_invalid",
});
/**
* Throw this from an adapter when a cursor is rejected outright: a stored
* anchor that no longer unarchives, a sync token a server answers as invalid.
* Health Connect reports expiry differently, as a flag on the response
* (changesTokenExpired), which the adapter returns as `cursorExpired: true`.
* Both routes lead to the same fallback.
*/
export class CursorInvalidError extends Error {
constructor(message, options) {
super(message, options);
this.name = "CursorInvalidError";
this.cursorInvalid = true;
}
}
export function isCursorRejected(err) {
return Boolean(err && err.cursorInvalid === true);
}
// ---------------------------------------------------------------------------
// Idempotent apply
// ---------------------------------------------------------------------------
const CIVIL_DATE = /^\d{4}-\d{2}-\d{2}$/;
function assertRecord(r) {
if (!r || typeof r.id !== "string" || r.id === "") {
throw new TypeError("every record needs the platform's id; deletions are id-only and cannot be resolved without it");
}
if (!Number.isFinite(r.version)) throw new TypeError(`record ${r.id} needs a numeric version`);
if (!Number.isFinite(r.start)) throw new TypeError(`record ${r.id} needs a start instant (epoch ms)`);
if (typeof r.localDate !== "string" || !CIVIL_DATE.test(r.localDate)) {
throw new TypeError(`record ${r.id} needs a civil localDate (YYYY-MM-DD) resolved where the offset is known`);
}
}
/**
* Apply one batch of changes in ONE store write. Safe to call twice with the
* same batch: the second call changes nothing and dirties nothing.
*
* Changes are `{ type: "upsert", record }` or `{ type: "delete", id }`.
* A record is `{ id, version, start, localDate, origin?, ...payload }`, where
* `version` is whatever moves when the record changes (Health Connect's
* metadata.lastModifiedTime as epoch ms; a constant for HealthKit, whose
* objects are immutable) and `localDate` is the civil day it belongs to.
*
* @param {object} store { read(id), writeBatch(rows) } — writeBatch is atomic
* @param {Array} changes
* @param {{ownOrigin?: string|null}} [opts] skip upserts your own app wrote
*/
export async function applyChanges(store, changes, { ownOrigin = null } = {}) {
const pending = new Map(); // id -> row, so a batch sees its own earlier changes
const dirty = new Set();
const counts = { upserted: 0, deleted: 0, unchanged: 0, ownWrites: 0, unknownDeletes: 0 };
async function current(id) {
return pending.has(id) ? pending.get(id) : await store.read(id);
}
for (const change of changes) {
if (change.type === "upsert") {
const r = change.record;
assertRecord(r);
if (ownOrigin !== null && r.origin === ownOrigin) {
// Re-importing what you wrote turns write-then-read into a loop.
counts.ownWrites += 1;
continue;
}
const held = await current(r.id);
if (held && held.version >= r.version) {
// Already have this version (a replay) or a newer one (out of order),
// and a tombstone keeps its version, so a replay cannot resurrect.
counts.unchanged += 1;
continue;
}
if (held && !held.deleted) dirty.add(held.localDate); // an edit can move a record between days
dirty.add(r.localDate);
pending.set(r.id, { id: r.id, version: r.version, start: r.start, localDate: r.localDate, deleted: false, record: r });
counts.upserted += 1;
} else if (change.type === "delete") {
const held = await current(change.id);
if (!held) {
// Nothing to resolve the id against. Without an id index built at
// ingest, this is where a deletion silently becomes unactionable.
counts.unknownDeletes += 1;
continue;
}
if (held.deleted) {
counts.unchanged += 1;
continue;
}
dirty.add(held.localDate);
// Keep identity, version and day; drop the payload.
pending.set(change.id, { ...held, deleted: true, record: null });
counts.deleted += 1;
} else {
throw new TypeError(`unknown change type: ${change && change.type}`);
}
}
if (pending.size > 0) await store.writeBatch([...pending.values()]);
return { ...counts, dirtyDays: [...dirty].sort() };
}
// ---------------------------------------------------------------------------
// Full re-read helpers
// ---------------------------------------------------------------------------
/**
* Where a fallback re-read starts. Google's most-ideal strategy is to
* "re-read all data from the most recent timestamp or for the last 30 days"
* and dedupe by identifier; this takes whichever reaches further back, so a
* retro-edit inside the lookback is caught even when the last read was recent.
*/
export function resyncSince(state, nowMs, lookbackMs = DEFAULT_RESYNC_LOOKBACK_MS) {
const floor = nowMs - lookbackMs;
const last = state && state.lastReadAt;
return Number.isFinite(last) ? Math.min(last, floor) : floor;
}
/**
* Build a `fullRead` for a platform that issues cursors separately from reads
* (Health Connect's getChangesToken). The cursor is taken FIRST: anything that
* changes during the re-read is then replayed by the next incremental sync,
* which the idempotent apply absorbs. Taken last, that change is lost.
*
* HealthKit does not need this: an anchored query run with a nil anchor hands
* back its results and the anchor to continue from in the same handler call.
*/
export function cursorFirstFullRead({ getCursor, readSince }) {
return async function fullRead({ since }) {
const cursor = await getCursor();
const records = await readSince({ since });
return { cursor, records };
};
}
// ---------------------------------------------------------------------------
// The sync run
// ---------------------------------------------------------------------------
/**
* One sync for one cursor key, typically (user, provider, record type). Use
* one cursor per record type: Health Connect deletions carry no type, and
* Google recommends separate tokens per data type.
*
* @param {object} opts
* @param {string} opts.key
* @param {object} opts.source
* getChanges(cursor) -> { changes, nextCursor, hasMore?, cursorExpired? }
* fullRead({ since }) -> { cursor, records } since is null on first sync
* @param {object} opts.cursorStore { load(key), save(key, state) }
* @param {object} opts.recordStore { read(id), writeBatch(rows), listLive({ since }) }
* @param {string|null} [opts.ownOrigin]
* @param {number} [opts.resyncLookbackMs]
* @param {boolean} [opts.reconcileDeletions=true] resync only; correct only
* when fullRead returns EVERY record in the window, under the same filter
* as the change feed
* @param {number} [opts.maxPages=Infinity] stop early (e.g. leaving the
* foreground); the saved cursor resumes exactly where this run stopped
* @param {() => number} [opts.now]
* @param {(event: object) => void} [opts.onEvent]
*/
export async function runSync({
key,
source,
cursorStore,
recordStore,
ownOrigin = null,
resyncLookbackMs = DEFAULT_RESYNC_LOOKBACK_MS,
reconcileDeletions = true,
maxPages = Infinity,
now = Date.now,
onEvent = () => {},
}) {
if (!key) throw new TypeError("key is required");
if (!cursorStore || typeof cursorStore.load !== "function" || typeof cursorStore.save !== "function") {
throw new TypeError("cursorStore must implement load(key) and save(key, state)");
}
if (!recordStore || typeof recordStore.read !== "function" || typeof recordStore.writeBatch !== "function") {
throw new TypeError("recordStore must implement read(id) and writeBatch(rows)");
}
const startedAt = now();
const state = await cursorStore.load(key);
const dirty = new Set();
const totals = { upserted: 0, deleted: 0, unchanged: 0, ownWrites: 0, unknownDeletes: 0 };
function absorb(result) {
for (const k of Object.keys(totals)) totals[k] += result[k];
for (const d of result.dirtyDays) dirty.add(d);
}
function summary(mode, extra) {
return { key, mode, ...extra, ...totals, dirtyDays: [...dirty].sort() };
}
async function resync(reason) {
const mode = reason === RESYNC_REASON.NO_CURSOR ? SYNC_MODE.INITIAL : SYNC_MODE.RESYNC;
const since = mode === SYNC_MODE.INITIAL ? null : resyncSince(state, startedAt, resyncLookbackMs);
onEvent({ type: "resync", key, reason, since });
const { cursor, records } = await source.fullRead({ since });
const changes = records.map((record) => ({ type: "upsert", record }));
// Never on a first sync: if rows are already held (a reinstall, a lost
// cursor store), an initial read that returns less may only mean the
// platform's readable window moved, and absence there proves nothing.
if (mode === SYNC_MODE.RESYNC && reconcileDeletions && typeof recordStore.listLive === "function") {
// A deletion made while the cursor was dead never reaches the change
// feed. Anything held in the window that the re-read did not return is
// gone at the source.
const returned = new Set(records.map((r) => r.id));
for (const row of await recordStore.listLive({ since })) {
if (!returned.has(row.id)) changes.push({ type: "delete", id: row.id });
}
}
absorb(await applyChanges(recordStore, changes, { ownOrigin }));
// Saved last, as always: the re-read is durable before the cursor moves.
await cursorStore.save(key, { cursor, lastReadAt: startedAt, lastSyncedAt: now(), mode });
return summary(mode, { reason, pages: 0, reread: records.length, hasMore: false, cursor });
}
if (!state || state.cursor === null || state.cursor === undefined) {
return resync(RESYNC_REASON.NO_CURSOR);
}
let cursor = state.cursor;
let pages = 0;
for (;;) {
let page;
try {
page = await source.getChanges(cursor);
} catch (err) {
if (isCursorRejected(err)) return resync(RESYNC_REASON.CURSOR_INVALID);
// Not ours to interpret. The stored cursor is still the last good one.
throw err;
}
// A flag, not a throw: a try/catch alone would read this as an empty sync.
if (page.cursorExpired === true) return resync(RESYNC_REASON.CURSOR_EXPIRED);
absorb(await applyChanges(recordStore, page.changes ?? [], { ownOrigin }));
cursor = page.nextCursor;
await cursorStore.save(key, {
cursor,
lastReadAt: startedAt,
lastSyncedAt: now(),
mode: SYNC_MODE.INCREMENTAL,
});
pages += 1;
onEvent({ type: "page", key, pages });
if (page.hasMore !== true) return summary(SYNC_MODE.INCREMENTAL, { pages, hasMore: false, cursor });
if (pages >= maxPages) return summary(SYNC_MODE.INCREMENTAL, { pages, hasMore: true, cursor });
}
}
// ---------------------------------------------------------------------------
// Reference stores
// ---------------------------------------------------------------------------
/**
* Reference record store. In Postgres, writeBatch is one transaction of
* upserts keyed on the platform id; listLive is the window query the
* reconciliation diffs against. Tombstones must be purged with the user on
* erasure, like everything else.
*/
export function createMemoryRecordStore() {
const rows = new Map();
let commits = 0;
return {
get commitCount() {
return commits;
},
async read(id) {
const row = rows.get(id);
return row ? structuredClone(row) : null;
},
async writeBatch(batch) {
for (const row of batch) rows.set(row.id, structuredClone(row));
commits += 1;
},
async listLive({ since = null } = {}) {
return [...rows.values()]
.filter((r) => !r.deleted && (since === null || r.start >= since))
.map((r) => structuredClone(r));
},
rows,
};
}
/** Reference cursor store: one row per key, the whole state in one write. */
export function createMemoryCursorStore(seed = {}) {
const data = new Map(Object.entries(seed));
let saves = 0;
return {
get saveCount() {
return saves;
},
async load(key) {
const v = data.get(key);
return v ? structuredClone(v) : null;
},
async save(key, state) {
saves += 1;
data.set(key, structuredClone(state));
},
};
}
How to adapt it#
Keep the adapter thin. Its only job is translation: native changes into { type: "upsert", record } and { type: "delete", id }, and native expiry into the flag or the error. Resolve each record's civil localDate on the device, where the recording offset is knowable — timezones and day boundaries covers why it cannot be recovered later.
Use one cursor per record type. Health Connect's guide says a DeletionChange carries only the record id, "not the record type, due to privacy", and recommends separate tokens per data type so one revoked permission does not take down the batch. Key the cursor on (user, provider, record type).
Replace both reference stores. writeBatch must be atomic: one transaction of upserts keyed on the platform id. If the cursor and the rows live in the same database, write both in one transaction and the replay window disappears entirely; the recipe does not assume that, because on mobile the cursor usually lives on the device and the rows on your server.
Alert on lastSyncedAt, not on the cursor. The cursor is opaque. The timestamp saved beside it is not, and it is how you notice a user drifting toward the 30-day mark before their token expires and turns a cheap delta into a re-read.
Turn off reconciliation if your re-read is partial. The set-difference is only correct when fullRead returns every record of the type in the window under the same filter as the change feed. If your read is truncated, filtered by origin, or limited by what the platform lets you read, pass reconcileDeletions: false and rely on a scheduled full-window reconciliation instead.
Watch unknownDeletes. A deletion whose id you never stored is a counted no-op. A steady non-zero count means ids are not being persisted at ingest, and Google's guide is blunt that if your app reads from Health Connect, "you must store the id."
Feed dirtyDays into a recompute queue. Each day is then recomputed from raw rows; for a multi-source metric, the dedupe recipe is that recompute.
The contract the tests hold it to#
Each bullet is one test in cookbook/incremental-sync-anchor.test.mjs. The fake platform is shaped like Health Connect's change log: tokens are positions, pages are bounded, and expiry is reported on the response.
- A first sync takes the cursor before reading, reads with no lookback, stores every record, returns the two civil days touched rather than a sum, and saves the cursor with the time of the read.
- A first sync with rows already held tombstones nothing, even when the read returns fewer records than the store holds.
- With a store that commits on a later tick, the event order is apply, commit, cursor save — for every page, never interleaved.
- A crash inside the apply leaves the old cursor and zero cursor writes; the rerun applies the page once.
- A crash between the apply and the cursor save replays exactly one page, and the replay recognises every record, writes nothing and dirties no days.
- Five changes in pages of two produce three fetches from three successive cursors and three cursor saves.
- Stopping at
maxPagesreports that more is waiting, and the next run resumes from the saved cursor without fetching the first page again. - An expired token is spent once and never again; the fallback takes a new cursor before re-reading from the last read 43 days earlier, tombstones the record deleted while the token was dead, applies the edit, adds the new record, recognises the untouched one, and dirties only the three days that changed.
- The re-read window is 30 days after a recent read, reaches back to the last read after a long gap, and honours a custom lookback.
- A cursor rejected with
CursorInvalidErrortakes the same fallback. - Any other error is rethrown, triggers no re-read, and leaves the stored cursor and its save count unchanged.
- A record written while the re-read is in flight reaches the store on the next sync when the cursor is taken first, and is lost for good when it is taken after.
- Upserts carrying your own app's origin are counted and skipped.
- Applying the same batch twice writes once; the batch sees its own earlier upsert before deleting it, and the replayed upsert does not resurrect the tombstone.
- A stale version is ignored, and an edit that moves a record to a new civil day dirties both days.
- A deletion for an unknown id is a counted no-op, a batch containing a record with no civil date writes nothing at all, and an unknown change type throws.
What it deliberately does not do#
It does not decide when a sync runs. Getting the device to wake up and run this at all is the job of background sync; Google's guide is explicit that a Health Connect app "can't get notified of new data" and has to go and check. It cannot recover a HealthKit deletion that the system purged before you ran, beyond what a re-read and set-difference over the window can infer. It cannot read further back than the platform lets your app read, which is the limit the incremental sync page works through. And it never produces a total: a dirty day is a request to recompute, and the recompute is a separate, authoritative read.
Frequently asked questions
- What happens if my app crashes after applying changes but before saving the new token?
- You fetch the same page again on the next run, and with an idempotent apply that costs nothing. The recipe saves the cursor strictly after the page's single store write has completed, so the only crash it cannot avoid is the one between those two writes, and that crash leaves the old cursor pointing at changes you have already applied. Its test suite kills the cursor save once and asserts that the rerun re-fetches exactly that page, recognises every record by id and version, writes nothing and dirties no days. The opposite order is the dangerous one: a cursor saved before the data is durable means a crash skips that page permanently, and no later sync can tell.
- Why take a new changes token before re-reading data rather than after?
- Because anything that changes while the re-read runs would otherwise fall into the gap between the two. A token marks a point in the platform's change log. Taken after the re-read finishes, a record written during the read is newer than your snapshot but older than your token, so no later sync will ever deliver it. Taken first, that same record arrives again on the next incremental sync, and an apply keyed on the record id and gated on its version absorbs the duplicate. Google's guide lists requesting a new token among its recovery steps without fixing when; the recipe takes it first, and its test suite shows the change surviving one way and lost the other.
- How far back should a full resync read after a sync token expires?
- Far enough to cover both your last successful read and the window in which retro-edits land. Google's most ideal recovery strategy is to re-read all data from the most recent timestamp or for the last 30 days, then dedupe it against what you already hold using identifiers. The recipe reads from whichever of those two points is further back, so a user whose last read was yesterday still gets a 30-day re-read that catches late edits, and a user idle for six weeks gets everything since their last read. Two limits remain. A re-read by time cannot see deletions, which is why the recipe diffs what it holds in the window against what came back, and it cannot reach past what the platform lets your app read.
- What should I use as the version when applying synced health records idempotently?
- Whatever moves when the record changes, compared so that an equal or older version is a no-op. On Health Connect that is the record metadata's lastModifiedTime, which Google's reference describes as automatically populated to when the data was last modified or originally created, and which the sync guide pairs with the record id for upserts. On HealthKit, Apple documents that objects are immutable apart from a few exceptions such as the source revision, so the sample's uuid alone makes a replay a no-op and a constant version is enough. Keep the version on tombstones as well, or a replayed upsert will resurrect a record you have already deleted.
Keep reading
Elsewhere on the site
Pages that share this one’s concepts and sources, from other sections.
- TroubleshootingHealth Connect changesTokenExpired: How to Resync Without Losing Data
- ArchitectureBackfilling Years of Wearable Data Without Hitting Rate Limits
- Health Connect APIREAD_HEALTH_DATA_IN_BACKGROUND: reading Health Connect data from a background worker
- Build GuidesAggregate-Only Employer Reporting and Wearable Sync for Corporate Wellness Apps
Next steps
Was this page useful?
Independent comparison, last reviewed October 2, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.
← All cookbook · by AIFitnessAPI