---
title: "getChanges and getChangesToken: incremental sync from Health Connect"
canonical: "https://aifitnessapi.com/health-connect-api/getchanges-incremental-sync"
cluster: "Health Connect API"
primary_query: "HealthConnectClient.getChanges"
last_reviewed: "2026-10-04"
description: "How getChangesToken and getChanges work: ChangesTokenRequest, the hasMore loop, nextChangesToken, UpsertionChange, DeletionChange and one token per type."
publisher: "AIFitnessAPI — independent, not sponsored"
cite_as: "\"getChanges and getChangesToken: incremental sync from Health Connect\", AIFitnessAPI, https://aifitnessapi.com/health-connect-api/getchanges-incremental-sync"
---

# getChanges and getChangesToken: incremental sync from Health Connect

> Health Connect's incremental sync is two calls: getChangesToken(ChangesTokenRequest(recordTypes)) returns a token that marks a point in time, and getChanges(token) returns a ChangesResponse listing the UpsertionChange and DeletionChange entries since then, plus a nextChangesToken. Google says one response may not hold every change, so you keep calling with nextChangesToken while hasMore is true and store the last token for next time. A DeletionChange carries only the deleted record's id, not its type, so you need the Health Connect ids of anything you copied.

- Canonical: https://aifitnessapi.com/health-connect-api/getchanges-incremental-sync
- Last reviewed: 2026-10-04
- Publisher: AIFitnessAPI (https://aifitnessapi.com) — independent, not sponsored
- Cite as: "getChanges and getChangesToken: incremental sync from Health Connect", AIFitnessAPI, https://aifitnessapi.com/health-connect-api/getchanges-incremental-sync

---

Re-reading a whole window to find out what changed misses deletions and wastes calls. Health Connect's changes API gives you a cursor instead. This page covers the token, the paging loop and the two kinds of change, from the [`HealthConnectClient` reference](https://developer.android.com/reference/kotlin/androidx/health/connect/client/HealthConnectClient), the [`ChangesTokenRequest`](https://developer.android.com/reference/kotlin/androidx/health/connect/client/request/ChangesTokenRequest), [`ChangesResponse`](https://developer.android.com/reference/kotlin/androidx/health/connect/client/response/ChangesResponse), [`UpsertionChange`](https://developer.android.com/reference/kotlin/androidx/health/connect/client/changes/UpsertionChange) and [`DeletionChange`](https://developer.android.com/reference/kotlin/androidx/health/connect/client/changes/DeletionChange) references, and the [synchronize data guide](https://developer.android.com/health-and-fitness/health-connect/sync-data), all fetched October 4, 2026. What to do when a token has expired is a separate page: [Health Connect changesTokenExpired](/fix/health-connect-changes-token-expired).

## Getting a token

`suspend fun getChangesToken(request: ChangesTokenRequest): String` returns a token "representing a point in time in the underlying Android Health Platform". Google: "Changes-tokens represent a point in time after which the client is interested in knowing the changes for a set of interested types of `Record` and optional `DataOrigin` filters."

```kotlin
ChangesTokenRequest(
    recordTypes: Set<KClass<Record>>,
    dataOriginFilters: Set<DataOrigin> = setOf()
)
```

`recordTypes` is the "Set of `Record` types the token will observe change for", and `dataOriginFilters` is "Optional set of `DataOrigin` filters, default is empty set for no filter."

Google's guide recommends one token per type, for a stated reason: "We recommend getting separate tokens per data type instead of getting them in bulk to avoid having an `Exception` in case one of the permissions is revoked." Its best-practices section adds: "Only use a list of multiple data types with the Changes Sync API if these data types are either consumed together or not at all."

## Reading changes

There are two overloads:

```kotlin
suspend fun getChanges(changesToken: String): ChangesResponse
suspend fun getChanges(changesToken: String, pageSize: @IntRange(from = 1, to = 5000) Int): ChangesResponse
```

The first one's contract: "The response returned may not provide all the changes due to IPC or memory limits, see `ChangesResponse.hasMore`. Clients can make more api calls to fetch more changes from Health Connect with updated `ChangesResponse.nextChangesToken`." For the second, `pageSize` "is a soft limit that serves as a recommendation to the system", and "in certain cases the response might exceed the `pageSize`." Both list `RemoteException` and `SecurityException`.

| `ChangesResponse` property | Google's description |
| --- | --- |
| `changes: List<Change>` | "List of observed changes from Health Connect." |
| `hasMore: Boolean` | "Whether there are more changes available to be fetched again." |
| `nextChangesToken: String` | "Changes-token to keep for future calls." |
| `changesTokenExpired: Boolean` | "Whether requested Changes-Token has expired." Handled on [its own page](/fix/health-connect-changes-token-expired). |

`Change` is an interface with two implementations. `UpsertionChange` is "A `Change` with inserted or updated `Record`", exposed as `record`. `DeletionChange` exposes only `recordId`, and the reference explains why: "For privacy, only unique identifiers of the deletion are returned." All of these classes are labelled "Added in 1.1.0" in the reference; the two `getChanges` overloads and `getChangesToken` carry no label of their own.

## A sync pass for one record type

```kotlin
suspend fun pullWeightChanges(
    client: HealthConnectClient,
    savedToken: String,
    ownPackage: String,
    store: WeightStore // your persistence
): String {
    var token = savedToken
    do {
        val response = client.getChanges(token)
        if (response.changesTokenExpired) {
            throw TokenExpired() // your type; recover as the expired-token page describes
        }
        for (change in response.changes) {
            when (change) {
                is UpsertionChange ->
                    if (change.record.metadata.dataOrigin.packageName != ownPackage) {
                        store.upsert(change.record.metadata.id, change.record as WeightRecord)
                    }
                is DeletionChange -> store.deleteByHealthConnectId(change.recordId)
            }
        }
        token = response.nextChangesToken
    } while (response.hasMore)
    return token // persist only after the changes above are applied
}
```

The structure follows Google's `processChanges` sample. The token used for the first pass comes from `getChangesToken(ChangesTokenRequest(recordTypes = setOf(WeightRecord::class)))`. Taking that token before your initial full read, so writes made during the read appear in the first `getChanges`, is our ordering; Google's guide does not prescribe one.

## Skipping your own writes, and why ids matter

- **Your own upserts.** Google's steps say: "For `UpsertionChange`, only take changes that didn't come from the calling app to make sure you're not re-importing data." Its sample compares `change.record.metadata.dataOrigin.packageName` with the app's package name.
- **What to key on.** "For `UpsertionChange`, use the `id` and the `lastModifiedTime` from its `metadata` to upsert the record. For `DeletionChange`, use the `id` provided to delete the record."
- **Store ids when you write.** "If your app needs to read data from Health Connect, you must store the `id`. This is necessary to correctly process deletion changelogs, as `DeletionChange` notifications only provide the record `id`." The ids come back from `insertRecords`, covered on the [clientRecordId upsert page](/health-connect-api/insertrecords-clientrecordid-upsert).
- **Deletions have no type.** Because a `DeletionChange` omits the record type, Google says "you can either specify only 1 data type for each call of `getChanges`, or make sure that you've stored this information separately beforehand."

## When to call it

The sync guide is plain about the limits: "As your app can't get notified of new data, check for new data at two points": each time the app comes to the foreground, and periodically while it stays there. Reading in the background takes an extra permission the user must grant. Tokens do not last: `getChangesToken`'s reference says they "are only valid for 30 days after they're generated", so a user who does not open the app for a month will hand you an expired one. The design behind cursor-based sync on both platforms is in [incremental sync](/architecture/incremental-sync), and a tested version of the bookkeeping is the [incremental sync anchor recipe](/cookbook/incremental-sync-anchor).

## Pitfalls

- **Saving the token before applying the batch.** If the app dies between saving `nextChangesToken` and writing the changes, those changes are gone. Save it after.
- **One token for several independent types.** Google recommends separate tokens "to avoid having an `Exception` in case one of the permissions is revoked."
- **Re-importing your own data.** Without the `dataOrigin` check, every record you write comes back to you as an upsert.
- **Not storing Health Connect ids.** Without them, a `DeletionChange` cannot be matched to anything you hold.
- **Treating `pageSize` as exact.** It is a soft limit; size your processing for responses larger than requested.
- **Reading `changesTokenExpired` as "no changes".** It is a flag on a normal response, not an exception. The recovery is on the [expired token page](/fix/health-connect-changes-token-expired).

## FAQ

### What does hasMore mean in a Health Connect ChangesResponse?

It means the response did not include every pending change. Google's getChanges reference says a response may not provide all the changes due to IPC or memory limits, and that clients can make more calls with the updated nextChangesToken. Keep calling getChanges with each nextChangesToken while hasMore is true, then store the last token for your next sync. hasMore being false only means you are caught up for now.

[Permalink](https://aifitnessapi.com/health-connect-api/getchanges-incremental-sync#faq-1)

### Why does a Health Connect DeletionChange not include the record type?

For privacy. Google's DeletionChange reference says only the unique identifiers of the deletion are returned, exposed as recordId, which is the Metadata.id of the deleted record. Google's sync guide says to either request changes for only one data type per getChanges call or store each record's type yourself beforehand, and to keep the Health Connect id of every record you copy so a deletion can be matched.

[Permalink](https://aifitnessapi.com/health-connect-api/getchanges-incremental-sync#faq-2)

### Should one Health Connect changes token cover several record types?

Usually not. Google's sync guide recommends getting separate tokens per data type instead of in bulk, to avoid an exception if one of the permissions is revoked, and says to combine types in one token only if they are consumed together or not at all. One token per type also lets you know which type a DeletionChange belongs to.

[Permalink](https://aifitnessapi.com/health-connect-api/getchanges-incremental-sync#faq-3)

### Can getChanges hand my app back the records it wrote itself?

Google's sync guide treats that as expected. Its steps say that for an UpsertionChange you should only take changes that did not come from the calling app, so you do not re-import data, and its sample skips any change whose record's dataOrigin package name equals the app's own package name. Apply the same check before writing upserts into your datastore.

[Permalink](https://aifitnessapi.com/health-connect-api/getchanges-incremental-sync#faq-4)
