getChanges and getChangesToken: incremental sync from Health Connect
Last verified October 4, 2026 · 5 min read
Covered here:Health Connect
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, the ChangesTokenRequest, ChangesResponse, UpsertionChange and DeletionChange references, and the synchronize data guide, all fetched October 4, 2026. What to do when a token has expired is a separate page: Health Connect changesTokenExpired.
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."
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:
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. |
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#
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 compareschange.record.metadata.dataOrigin.packageNamewith the app's package name. - What to key on. "For
UpsertionChange, use theidand thelastModifiedTimefrom itsmetadatato upsert the record. ForDeletionChange, use theidprovided 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, asDeletionChangenotifications only provide the recordid." The ids come back frominsertRecords, covered on the clientRecordId upsert page. - Deletions have no type. Because a
DeletionChangeomits the record type, Google says "you can either specify only 1 data type for each call ofgetChanges, 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, and a tested version of the bookkeeping is the incremental sync anchor recipe.
Pitfalls#
- Saving the token before applying the batch. If the app dies between saving
nextChangesTokenand writing the changes, those changes are gone. Save it after. - One token for several independent types. Google recommends separate tokens "to avoid having an
Exceptionin case one of the permissions is revoked." - Re-importing your own data. Without the
dataOrigincheck, every record you write comes back to you as an upsert. - Not storing Health Connect ids. Without them, a
DeletionChangecannot be matched to anything you hold. - Treating
pageSizeas exact. It is a soft limit; size your processing for responses larger than requested. - Reading
changesTokenExpiredas "no changes". It is a flag on a normal response, not an exception. The recovery is on the expired token page.
Frequently asked questions
- 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.
- 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.
- 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.
- 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.
Keep reading
Elsewhere on the site
Pages that share this one’s concepts and sources, from other sections.
Next steps
Was this page useful?
Independent comparison, last reviewed October 4, 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 health connect api · by AIFitnessAPI