GSSNS

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:

SourceHowFile
WatchCopied straight off the Fenix 6 over USB (MTP) with Unstrapgarmin_device_usb.fit
Account exportgarmin.com > Data Management > Export Your Data, the full GDPR exportgarmin_gdpr_dump.fit
Export OriginalGarmin Connect web, activity page > gear icon > Export Originalgarmin_connect_web.fit
Connect APIThe 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:

FileBytesSHA-256FIT profile
Watch (USB)147,212b9b161ae81db…21.126
Account export147,212b9b161ae81db…21.126
Export Original147,212b9b161ae81db…21.126
Connect API72,85637149646b800…21.217

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 isKept in APIDropped from API
Documented23,0690
Undocumented07,070
Developer field (Connect IQ)10

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_numMessagesWhat’s in it
324348Undocumented. One every ~6 seconds, each holding 8 opaque bytes
14716Looks like the watch’s saved sensors (HRM-Pro:…, Rally:…, SRM_PM9_…)
228Undocumented
3272Undocumented
79, 113, 140, 141, 288, 3261 eachUndocumented

Undocumented fields in documented messages (5,587 values). The message survives, but these fields are removed:

MessageUndocumented fieldsValues dropped
record3 (fields 90, 134, 144)5,354
device_settings8282
device_info852
training_settings3838
battery, user_profile, sport, session, zones_target, lap, file_creator, activity4261

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.

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.)