How I Built a Webhook That Pairs My Runs With the Music I Ran To
I run with music, and I kept wanting to know the thing fitness apps never tell you: which song was actually playing when I hit my fastest kilometre? Not my average BPM for the week — the specific track, at the specific bend in the road, at the specific pace.
So I built a little system that answers it automatically. The moment I finish a run, it stitches my Strava activity together with my listening history and files away a record of which song soundtracked which stretch of road. I never open it or trigger it. It just happens.
Here’s how the pipeline works, and — more interestingly — the handful of design decisions that turned a fragile weekend hack into something that’s quietly run for months without me babysitting it.
The shape of it
The whole thing is one Cloudflare Worker: no server, no container, no cron job I have to keep alive. Strava pokes it; it does its work; it writes the result to two places and goes back to sleep.
The pipeline: one webhook in, two independent stores out.
When I finish a run, Strava sends a POST /webhook. The Worker fetches the full activity
plus its GPS, heart-rate and pace streams, pulls my listening history for that exact time
window, and matches each track to the points on the map where it was playing. Then it writes
everything down. Total human involvement: zero.
The fun part wasn’t the happy path — it was the four decisions that made it survive contact with reality.
Decision 1 — Coverage over richness
Spotify has a lovely API for “what did I listen to recently” — except it only returns your last 50 tracks, globally, and it never sees anything you played offline. On a long run, or a run where I’d downloaded a playlist for the subway, the music history just… had holes.
I could have accepted the gaps. Instead, when Spotify comes back thin, the Worker falls back to Last.fm scrobbles, which catch offline plays and don’t cap at 50. The tradeoff is real: Last.fm doesn’t give me Spotify’s audio features (no tempo, energy, or “valence”). But I decided that knowing what played beats knowing its audio profile — coverage over richness. A run with a known soundtrack and no BPM is far more useful than a run that’s simply blank.
Decision 2 — Treat the refresh token as a moving target
This one bit me in production. Strava rotates your refresh token every single time you use it — the old one is immediately dead. If you bake the token into a config value, your integration works perfectly… exactly once, and then silently stops the next day.
The fix was to stop treating the token as a constant. The Worker keeps the latest token in its database, reads from there before every refresh, falls back to the bootstrap secret only if the database has nothing, and writes back any new token the provider hands over. And crucially: if that write fails, it’s swallowed — a storage hiccup never takes down the actual token refresh. The thing that was breaking me is now the thing that quietly heals itself.
Decision 3 — Two stores that don’t depend on each other
Every activity gets written twice: once to a SQLite database (the queryable source of truth) and once as a human-readable JSON file committed to a GitHub repo (a portable, diffable mirror I can actually read).
The point isn’t redundancy for its own sake — it’s that the two writes are independent. If the GitHub commit fails, the database still has the data. If the database write fails, the commit still goes through. There’s no step where one store failing loses the activity. Two cheap, unglamorous stores that fail in different ways beat one “proper” store that fails all at once.
Decision 4 — Acknowledge first, work later
Webhooks have a trap: the sender usually retries aggressively on any non-200 response. If your handler does the real work before replying, then a slow API call or a transient error makes the sender think you failed — so it resends, and now you’re processing the same run twice, or five times.
So the Worker does the opposite. It replies 200 OK immediately, the instant the event
arrives, and only then — after the handshake is done — does it go fetch streams, call Spotify,
and write to storage. Strava is happy the moment it’s acknowledged; all the work that can fail
happens safely out of band, where a failure is logged instead of amplified.
What it gets me
The end result is a quiet little dataset that grows on its own. I can ask it things like “what was playing during my fastest kilometre last month?” and get a real answer — and the fitness page on this site draws each route coloured by the emotional tone of the music I was listening to along the way.
None of the four decisions were clever. They were just honest about how the pieces actually fail: APIs cap and rotate, networks blip, and senders retry. Build around that, and a weekend hack turns into something that runs for months while you forget it exists. That, to me, is the whole point of good systems work.
// END OF TRANSMISSION