
From the first Garmin swim to a details page in one evening
Garmin Connect started pushing swims to Vimo Swim on October 29. The summary showed up on the site within minutes, the laps and heart rate took one more evening. What made it quick: store every push raw before you parse anything.
At 22:20 on October 29 I finished a pool swim with a Garmin on my wrist. A few minutes later the Vimo Swim website showed it under My swims: pool swim, 3:28, average heart rate 46 bpm. A test swim, not a workout, but the first real activity that travelled from Garmin Connect to our backend without anyone touching it. By midnight the same swim had a details page with the heart-rate line and the lap split. This note is about why the second half took only an evening.
What Garmin actually sends
Garmin's Activity API does not send one payload. It sends three pushes per activity, to three callback URLs you register in the developer portal:
activities: the summary. Duration, distance, average and max heart rate, strokes, lengths, pool length.activity-details: the same summary again, pluslapsandsamples. For that 3:28 swim that was 1 lap and 209 samples, one per second.activity-files: a callback URL to download the FIT file.
They arrive in no guaranteed order. The details can beat the summary, the file can beat both. Our first version only parsed the summary into a swim row and downloaded the FIT file to look for the Vimo developer fields. The details push was written to the database untouched, with a comment in the code that said "nothing reads them yet".
Why raw-first paid off
Every push, parsed or not, lands as one row in a push-events table: kind, Garmin user id, the JSON body, received time, and an error column. The parsing happens after the row exists and never raises past that point. I did it that way for debugging, so a bad payload could be replayed. It turned out to be the feature that made the details page cheap.
When I wanted laps and heart rate on the site, the data for the first swim was already in the database. The backend change was a parser for one more kind, three columns on the swim row, and a management command that re-runs the parser over the stored events:
manage.py garmin_connect_backfill_details
details attached to 1 swim(s), 0 error(s)
No re-sync, no asking Garmin for history, no waiting for the next swim. The samples are trimmed to a whitelist of keys before they are stored, because Garmin also sends steps and elevation for a swim, which is nothing but weight on the row.
Store the webhook body before you parse it, per user, with the error next to it. Every later feature that needs a field you ignored becomes a backfill instead of a migration project.
The part the docs do not say out loud
Garmin's laps carry one field: startTimeInSeconds. No duration, no distance, no heart rate per lap. If you want a lap table like the one on the watch, you build it from the per-second samples: a lap owns the samples from its start to the next lap's start, the distance is the difference of totalDistanceInMeters at both ends, the heart rate is the average of what is in between. The last lap keeps the closing sample. That is a small pure function with a unit test, and it is the whole lap table.
The heart-rate chart is an inline SVG, no chart library. One line, three gridlines, start and end time, the peak marked with a dot. It is read on a phone at the pool, so that is all the reading it gets.
The site is static Firebase hosting with no server. A route like /my-swims/<uuid> needs a rewrite to the prerendered /my-swims shell in firebase.json, or the catch-all serves the homepage and the router silently replaces your URL. We had the same pattern for /pair/<serial>, so it was a copy.
What it took
Two pull requests, backend and website, on the same branch name, merged in that order. The backend went out first so the endpoint existed when the page shipped. The one thing I would do differently is register all three Garmin callback URLs and parse all three kinds on day one, even into a JSON column. The raw table saved me this time, but a parser for a kind you already receive is an hour, and a details page that waits on it is a day you do not get back.
Extracting Data from Ableton .als and .asd Files
A deep dive into parsing Ableton Live project files. Learn what metadata hides inside .als and .asd files and how I extract it with Python.
Holacracy why and where we are
I have been testing Holacracy with my team at Bravelab.io for 6 months. Companies are complex organisms. We gave ourselves the space to experiment.