---
title: "Health Connect changesTokenExpired: How to Resync Without Losing Data"
canonical: "https://aifitnessapi.com/fix/health-connect-changes-token-expired"
cluster: "Troubleshooting"
primary_query: "health connect changestokenexpired"
last_reviewed: "2026-10-03"
description: "Google says Health Connect changes tokens are only valid for 30 days. What changesTokenExpired means, and Google's ranked recovery strategies."
publisher: "AIFitnessAPI — independent, not sponsored"
cite_as: "\"Health Connect changesTokenExpired: How to Resync Without Losing Data\", AIFitnessAPI, https://aifitnessapi.com/fix/health-connect-changes-token-expired"
---

# Health Connect changesTokenExpired: How to Resync Without Losing Data

> changesTokenExpired is a Boolean on ChangesResponse, not an exception. It is true when the token you passed to getChanges has expired, and Google documents that changes tokens "are only valid for 30 days after they're generated". When you see it, request a new token with getChangesToken, then re-read records from your last read timestamp or the last 30 days and dedupe them against what you already hold by record id. Google ranks that strategy as the most ideal.

- Canonical: https://aifitnessapi.com/fix/health-connect-changes-token-expired
- Last reviewed: 2026-10-03
- Publisher: AIFitnessAPI (https://aifitnessapi.com) — independent, not sponsored
- Cite as: "Health Connect changesTokenExpired: How to Resync Without Losing Data", AIFitnessAPI, https://aifitnessapi.com/fix/health-connect-changes-token-expired

---

Your incremental sync calls `getChanges(token)` and the response has `changesTokenExpired` set to true. Nothing threw. There are no changes in the list, and the token you stored can no longer tell you what happened since it was issued. Google documents this case and a recovery for it. The one serious mistake is treating the flag as "nothing changed".

## What Google documents

Android's Jetpack [`ChangesResponse` reference](https://developer.android.com/reference/kotlin/androidx/health/connect/client/response/ChangesResponse) defines the field as "Whether requested Changes-Token has expired." and says at class level: "If changesTokenExpired is true, clients need to generate a new one. This typically happens when clients have not synced changelog for extended period of time."

The [`HealthConnectClient` reference](https://developer.android.com/reference/kotlin/androidx/health/connect/client/HealthConnectClient) says the same thing from both ends:

- On `getChanges`: "Provided changesToken may have expired if clients have not synced for extended period of time (such as a month). In this case ChangesResponse.changesTokenExpired will be set, and clients should generate a new changes-token via getChangesToken."
- On `getChangesToken`: "Changes-tokens are only valid for 30 days after they're generated. Calls to getChanges with an expired changes-token will lead to ChangesResponse.changesTokenExpired"

Google's [sync guide](https://developer.android.com/health-and-fitness/health-connect/sync-data) puts it as "an unused Changes token expires within 30 days". The two wordings differ, "unused" against "after they're generated", but they lead to the same design. Every `getChanges` call hands you a `nextChangesToken`, so an app that syncs regularly always holds a recent one. An expired token means a user went about a month without a successful sync. (All fetched October 3, 2026.)

## Why this is a data-loss bug if you ignore it

`changesTokenExpired` is a Boolean on a normal response. If your loop only processes `response.changes` and saves `nextChangesToken`, an expired token looks exactly like a quiet month. Upserts and deletions from that window never reach your datastore. Deletions are the worst case: a record the user removed in Health Connect stays in your app indefinitely. Google's `DeletionChange` reference says only the `Metadata.id` of a deleted record is returned, "For privacy", so you cannot recover deletions by reading records again. A record that no longer exists simply does not come back.

## Google's recovery ladder

The sync guide's token-expiration advice is to "use a sync strategy that avoids losing information in such a case", and it lists three building blocks: search your datastore for the most recently consumed record that has a Health Connect id, request records from Health Connect starting at a specific timestamp and upsert them, and request a new Changes token to reserve for next time.

It then ranks four strategies, from most to least ideal:

1. **Read and dedupe all data**, "the most ideal strategy". Store the timestamp of your last read. "On token expiry, re-read all data from the most recent timestamp or for the last 30 days. Then, dedupe it against the previously read data using identifiers."
2. **Read only since the last read timestamp.** Google warns this "results in some data discrepancies around the time of Changes token expiry".
3. **Delete, then read the last 30 days**, which Google says "aligns more closely with what happens on the first integration".
4. **Read the last 30 days without deduping**, "the least ideal strategy", which "results in having duplicate data displayed to users".

## A resync that follows rung one

The sketch below uses only documented APIs: `getChanges`, `changesTokenExpired`, `nextChangesToken`, `hasMore`, `UpsertionChange.record`, `DeletionChange.recordId`, `getChangesToken` with `ChangesTokenRequest`, and a paged `readRecords`. `SyncStore` stands for your own persistence.

```kotlin
suspend fun syncWeight(client: HealthConnectClient, store: SyncStore, ownPackage: String) {
    val stored = store.changesToken("weight") ?: return resyncWeight(client, store)
    var next = stored
    do {
        val response = client.getChanges(next)
        if (response.changesTokenExpired) return resyncWeight(client, store)
        response.changes.forEach { change ->
            when (change) {
                is UpsertionChange ->
                    if (change.record.metadata.dataOrigin.packageName != ownPackage) {
                        store.upsert(change.record) // keyed on metadata.id
                    }
                is DeletionChange -> store.delete(change.recordId)
            }
        }
        next = response.nextChangesToken
    } while (response.hasMore)
    store.saveChangesToken("weight", next) // only after the batch is applied
}

suspend fun resyncWeight(client: HealthConnectClient, store: SyncStore) {
    // Take the new token BEFORE re-reading, so writes made during the
    // re-read show up in the next getChanges instead of falling in a gap.
    val fresh = client.getChangesToken(ChangesTokenRequest(recordTypes = setOf(WeightRecord::class)))
    val thirtyDaysAgo = Instant.now().minus(Duration.ofDays(30))
    val since = minOf(store.lastReadTime("weight") ?: thirtyDaysAgo, thirtyDaysAgo)
    var pageToken: String? = null
    do {
        val page = client.readRecords(
            ReadRecordsRequest(
                recordType = WeightRecord::class,
                timeRangeFilter = TimeRangeFilter.between(since, Instant.now()),
                pageToken = pageToken
            )
        )
        page.records.forEach { store.upsert(it) } // dedupe on metadata.id
        pageToken = page.pageToken
    } while (!pageToken.isNullOrEmpty())
    store.saveChangesToken("weight", fresh)
}
```

Three choices in there are ours, not Google's. We read from whichever is further back, the last read or 30 days ago, so a user whose last read was yesterday still gets a full 30-day re-read. We take the fresh token before the re-read: Google lists getting a new token as part of the strategy but does not fix the order, and taking it first trades a possible gap for an overlap that the dedupe absorbs. And we test `pageToken.isNullOrEmpty()`, because Google's read guide warns that `pageToken` "can return an empty string \"\" instead of null when pagination is complete" on some versions. The same bookkeeping, written for any cursor-based source, is the [incremental sync anchor recipe](/cookbook/incremental-sync-anchor).

The re-read cannot recover deletions, as noted above. For records the user deleted during the expired window, rung three (delete what you imported for the last 30 days, then read again) is the only one of Google's strategies that converges. Whether that cost is worth paying depends on whether a stale record that was deleted upstream is harmful in your product.

## Keep it from happening

- **One token per type.** The sync guide: "If your app consumes more than one data type independently, use separate Changes Tokens for each data type."
- **Sync on foreground.** Google's sync guide says that because your app "can't get notified of new data", it should check for new data each time it becomes active in the foreground. A user who opens your app at least once a month keeps a live token.
- **Background reads need permission.** Google's sync guide says apps can only read while in the foreground unless the user grants the Background Read permission. Without it, a user who never opens your app will outlive the token.
- **Mind the history permission.** Android's `HealthPermission` reference lists `getChanges` among the calls that `PERMISSION_READ_HEALTH_DATA_HISTORY` applies to. The default read window is covered in [Health Connect returns no data](/fix/health-connect-no-data).

## Where to go next

The design reasoning behind cursor-based sync on both platforms, including how this compares with HealthKit's anchors, is in [incremental sync](/architecture/incremental-sync). The setup this page assumes is in the [Google Health Connect integration guide](/integrate/google-health-connect).

## Sources

- [developer.android.com/reference/kotlin/androidx/health/connect/client/response/ChangesResponse](https://developer.android.com/reference/kotlin/androidx/health/connect/client/response/ChangesResponse) — changesTokenExpired: Whether requested Changes-Token has expired; class-level note (checked 2026-10-03)
- [developer.android.com/reference/kotlin/androidx/health/connect/client/HealthConnectClient](https://developer.android.com/reference/kotlin/androidx/health/connect/client/HealthConnectClient) — getChanges expiry note (such as a month); getChangesToken: only valid for 30 days after they're generated (checked 2026-10-03)
- [developer.android.com/health-and-fitness/health-connect/sync-data](https://developer.android.com/health-and-fitness/health-connect/sync-data) — unused token expires within 30 days; token-expiration approaches; ranked recovery strategies; per-type tokens; import timings (checked 2026-10-03)
- [developer.android.com/reference/kotlin/androidx/health/connect/client/changes/DeletionChange](https://developer.android.com/reference/kotlin/androidx/health/connect/client/changes/DeletionChange) — DeletionChange returns only Metadata.id, for privacy; recordId (checked 2026-10-03)
- [developer.android.com/reference/kotlin/androidx/health/connect/client/response/ReadRecordsResponse](https://developer.android.com/reference/kotlin/androidx/health/connect/client/response/ReadRecordsResponse) — pageToken null when no more pages (checked 2026-10-03)
- [developer.android.com/health-and-fitness/health-connect/read-data](https://developer.android.com/health-and-fitness/health-connect/read-data) — pageToken may be an empty string when pagination completes; background read permission (checked 2026-10-03)
- [developer.android.com/reference/kotlin/androidx/health/connect/client/permission/HealthPermission](https://developer.android.com/reference/kotlin/androidx/health/connect/client/permission/HealthPermission) — PERMISSION_READ_HEALTH_DATA_HISTORY applies to getChanges (checked 2026-10-03)

## FAQ

### Does getChanges throw an exception when the changes token has expired?

No. Android's reference says that in this case ChangesResponse.changesTokenExpired will be set, and clients should generate a new changes-token via getChangesToken. The call returns normally, so a loop that only reads the changes list and saves the next token will treat an expired token as a quiet period and silently skip everything that happened.

[Permalink](https://aifitnessapi.com/fix/health-connect-changes-token-expired#faq-1)

### How long is a Health Connect changes token valid?

Android's getChangesToken reference says changes tokens are only valid for 30 days after they're generated, and Google's sync guide says an unused Changes token expires within 30 days. Each getChanges call returns a nextChangesToken, so an app that syncs at least every few weeks always holds a recent token.

[Permalink](https://aifitnessapi.com/fix/health-connect-changes-token-expired#faq-2)

### How do I avoid duplicates when resyncing after token expiry?

Dedupe on identifiers. Google's most ideal strategy is to re-read from your last read timestamp or the last 30 days and dedupe against previously read data using identifiers. Key your datastore on each record's metadata id, so re-reading the same record updates it instead of inserting a second copy. Google also recommends setting a client record id on records your app writes.

[Permalink](https://aifitnessapi.com/fix/health-connect-changes-token-expired#faq-3)

### Can a resync after changesTokenExpired recover deleted records?

Not by reading. Health Connect reports deletions only as a DeletionChange carrying the deleted record's id, and a deleted record does not come back from a fresh read. If deletions in the expired window matter for your product, use Google's delete-then-read strategy: remove what you imported for the last 30 days and read it again.

[Permalink](https://aifitnessapi.com/fix/health-connect-changes-token-expired#faq-4)
