One activity, four FIT files
TL;DR: The FIT file on your Garmin watch and the files from Garmin’s two manual exports are byte-identical. The Connect API gives you a rewritten copy that is half the size. There’s maybe some stripping of proprietary data happening, but it’s definitely also about just handing developers a clean, standardized and recent FIT file.
In his write-up of Garmin’s new developer program, DCRainmaker added a caveat about the FIT files that apps get through the API:
“it does sound like Garmin is annoyingly stripping out some data they consider proprietary, whereas if you upload it manually from your watch/device, that data is there.”
“It does sound like” is a hypothesis. So I tested it.
The files
One activity: a 34-minute indoor ride (by yours truly) recorded on a Fenix 6, with a power meter and a heart rate strap. I got its FIT file four ways:
| Source | How | File |
|---|---|---|
| Watch | Copied straight off the Fenix 6 over USB (MTP) with Unstrap | garmin_device_usb.fit |
| Account export | garmin.com > Data Management > Export Your Data, the full GDPR export | garmin_gdpr_dump.fit |
| Export Original | Garmin Connect web, activity page > gear icon > Export Original | garmin_connect_web.fit |
| Connect API | The activity file Garmin sent to a Connect API app (SweatStack) | garmin_connect_api.fit |
I’m assuming Garmin sends the same file to every API app, whether it’s on the new Personal or Startup tiers or the legacy program.
Reproduce it
All you need is uv. Each script is a single file with its dependencies inline, and it expects the FIT files in the same folder:
mkdir garmin-fit && cd garmin-fit
for f in garmin_device_usb.fit garmin_gdpr_dump.fit garmin_connect_web.fit garmin_connect_api.fit \
fit_bytes.py fit_fields.py fit_records.py; do
curl -sO https://gssns.io/files/garmin-fit/$f
done
uv run fit_bytes.py # sizes, hashes, FIT headers
uv run fit_fields.py # every message and field: documented? kept? same value?
uv run fit_records.py # the second-by-second data, column by column
pyroparse does the parsing: a raw dump of every message, plus the record table. To decide whether a field is “documented”, fit_fields.py looks it up in the profile that ships with Garmin’s own FIT SDK. Every number below comes straight from their output.
Finding 1: three of the four are the watch’s file, byte for byte
fit_bytes.py:
| File | Bytes | SHA-256 | FIT profile |
|---|---|---|---|
| Watch (USB) | 147,212 | b9b161ae81db… | 21.126 |
| Account export | 147,212 | b9b161ae81db… | 21.126 |
| Export Original | 147,212 | b9b161ae81db… | 21.126 |
| Connect API | 72,856 | 37149646b800… | 21.217 |
- The account export and Export Original are byte-identical to the file on the watch. Garmin stores exactly what your watch wrote, and both manual routes give it back unchanged.
- The API file is a different file. It is half the size and has a different hash.
- The API file was rewritten. Its header carries a newer FIT profile version (21.217) than the one the watch wrote (21.126). Garmin didn’t trim the upload. It wrote a new file.
From here on, “original” means the watch’s file. The account export and Export Original are the same bytes.
Finding 2: the filter is Garmin’s public FIT profile
fit_fields.py checks every field value in the original against two questions: is the field in the public FIT profile (SDK 21.217, the version in the API file’s header), and is it in the API file?
| Field is | Kept in API | Dropped from API |
|---|---|---|
| Documented | 23,069 | 0 |
| Undocumented | 0 | 7,070 |
| Developer field (Connect IQ) | 1 | 0 |
- There are no exceptions. Every documented field is kept, and every undocumented field is dropped.
- None of the kept values changed (0 of 23,069).
- Message counts match for every message type that survives: same 2,018 records, 2,123 HRV messages, 2 laps and 1 session.
- Connect IQ developer data is kept, both the field definitions and the values.
So what the API strips has a precise definition: anything Garmin hasn’t documented in the public FIT SDK. That matches what Garmin reportedly told a developer: “Undocumented messages and fields are being removed before files are sent to third-party platforms” (Garmin forum). Users had already noticed missing fields (see this intervals.icu thread). These scripts show the rule is exact.
What you lose
The 7,070 dropped values come from two places.
10 undocumented message types (380 messages, 1,483 values). These are removed entirely:
| mesg_num | Messages | What’s in it |
|---|---|---|
| 324 | 348 | Undocumented. One every ~6 seconds, each holding 8 opaque bytes |
| 147 | 16 | Looks like the watch’s saved sensors (HRM-Pro:…, Rally:…, SRM_PM9_…) |
| 22 | 8 | Undocumented |
| 327 | 2 | Undocumented |
| 79, 113, 140, 141, 288, 326 | 1 each | Undocumented |
Undocumented fields in documented messages (5,587 values). The message survives, but these fields are removed:
| Message | Undocumented fields | Values dropped |
|---|---|---|
| record | 3 (fields 90, 134, 144) | 5,354 |
| device_settings | 82 | 82 |
| device_info | 8 | 52 |
| training_settings | 38 | 38 |
| battery, user_profile, sport, session, zones_target, lap, file_creator, activity | 42 | 61 |
Of the three record fields, fit_records.py shows that field 144 is an exact copy of heart_rate. Fields 90 and 134 carry per-second values that only exist in the original. What they mean, Garmin doesn’t say.
What you keep
fit_records.py: every record column that has data in the original is identical in the API file.
- Per second: heart rate, power, cadence, left/right balance, respiration rate, temperature, accumulated power.
- Beat to beat: all HRV (RR interval) data.
- Summaries: laps and session, including normalized power, TSS, IF, training effect, training load, respiration rate, RPE and feel.
- Thresholds and zones: max HR (187), threshold HR (167), FTP (280) and resting HR (49), the HR and power zone boundaries, and time in zone per lap and for the session.
- Context: device info, user profile (weight, height), events.
Why rewrite the file at all?
Garmin has been making watches and bike computers for well over a decade. Every one of them writes FIT files in the FIT version its firmware happened to ship with, plus its own undocumented extras. A ten-year-old Forerunner, this Fenix 6 and a brand-new Edge all write slightly different files.
If Garmin forwarded those files as they are, every app would have to deal with all of that history. Instead, Garmin reads each file and writes it out again in the current FIT version, keeping only what the public documentation describes. Whatever device recorded the activity, the app gets one kind of file.
Think of it as a translator who brings every old letter into today’s language. The words with no known meaning are left out, but to an app they would have been numbers without a name anyway.
Seen that way, the rewrite and the filter are one feature: a clean, current, documented file for every app. That is probably a help to most developers, not a way to guard secrets. The same data comes off the watch over USB and out of both of Garmin’s own exports, after all.
So, is DCRainmaker right?
About the data: yes. The API file holds less than the file on your watch, and both manual exports give you that full file.
About “proprietary”: not quite. What gets removed isn’t a hand-picked list of secrets. It’s everything Garmin hasn’t documented, and the watch and Garmin’s own exports hand that data out freely.
About the reason: probably developer convenience. Every app gets a file in one current FIT version with documented fields only, whatever device recorded it. For this activity, nothing an app can read with the public FIT SDK was lost.
About “it just increases the ways things can go wrong”: yes. The same activity now exists as two different files, with different hashes and different profile versions. Any app that deduplicates by file hash, or that gets the same activity through both the API and a manual upload, has to deal with that. The easy fix: let apps ask for the original file as well. Garmin already stores it byte for byte.
(Scripts: fit_bytes.py, fit_fields.py, fit_records.py. If your activities show something different, I’d love to hear about it.)