Kayaking Geek, one of our own sites (see also: how we host it for three cents a month), publishes trip reports for sea kayaking and whitewater paddling. The goal was simple: after a paddle logged on a Garmin watch, a draft post should already be waiting — route map, distance, moving time, elevation gain, average speed — with nothing left to do but write the actual story on top of it and hit publish. Getting there took three attempts, and the failures are the more useful part.
Plan A: Strava's API, riding on Garmin's auto-share
Garmin's own developer program is business/institution-only and closed to new individual applicants, so that was never on the table. But Garmin Connect can auto-share activities to Strava, and Strava has a normal, self-service developer API. That worked — until Strava moved its free API tier behind a paid subscription in mid-2026: $11.99/month, on top of whatever a Strava subscription itself costs, just to keep pulling your own activity data programmatically. For a personal blog, that's a subscription bought purely to avoid clicking "export," and it doesn't scale to "hourly script."
Plan B: Call Garmin directly (unofficial, and it worked — locally)
Garmin's own web API isn't officially published, but it's been reverse-engineered enough that calling it directly is possible. It worked fine testing by hand. It failed the moment it had to run unattended: the Garmin account has multi-factor authentication enabled, and there's no way for an hourly cron job to answer an MFA prompt, and no way to turn 2FA off on that account. This is the wall — not a rate limit, not a missing field, but an authentication step that specifically requires a human to be present at the exact moment it's asked.
The tempting next move is finding a way around the MFA prompt. That's the wrong instinct — 2FA is there on purpose, and defeating it programmatically is a security regression dressed up as a workaround. The actual fix is to stop trying to make the unattended script be the authenticated party at all.
Plan C: FitnessSyncer, as a delegated-auth relay
FitnessSyncer solves exactly this problem: you connect your Garmin account to FitnessSyncer once, interactively, in a browser — a human is there to answer the MFA prompt, one time. After that, FitnessSyncer's own servers keep that Garmin connection alive. The sync script never touches Garmin's login again; it only talks to FitnessSyncer's own published, documented REST API (OAuth2, with a published OpenAPI spec) to ask "any new activities?" The free plan allows up to five connected sources, which is plenty for a single Garmin account.
The field names the script reads — distanceKM, elevation_gain, the
gps.lap[].points[].point.{lat,lng} trackpoint shape — come straight from
FitnessSyncer's published API spec, not from guessing at an unofficial response. The script still
uses defensive .get() fallbacks throughout, but as normal defensive coding style,
not as a hedge against fields that might not actually exist.
What the hourly job actually does
A GitHub Actions workflow runs hourly. For each new activity FitnessSyncer reports, it builds a
route map by plotting the activity's GPS trackpoints over OpenStreetMap tiles with the
staticmap Python package — no map API key or account required — computes the
headline stats, and creates a draft post (never published automatically) via
WordPress's own REST API, authenticated with an Application Password. Each draft ships with an
empty image gallery block, ready for trip photos to be dropped in before publishing.
Why this runs from GitHub Actions and not the site itself
This ties directly back to how the site is hosted: the Lambda function behind Kayaking Geek has no outbound internet access at all — a deliberate trade-off that's most of how the hosting bill stays near zero. It genuinely cannot call FitnessSyncer's API on its own. GitHub Actions does the outbound leg (calling FitnessSyncer), then creates each post by calling into the site's own REST API — an inbound request, which the site answers fine regardless of its own outbound restrictions.
A small state file tracks the last-synced activity time and gets committed back to the repo by the workflow itself after each run, so the very first run only picks up activities going forward — not an entire Garmin history all at once.
The pattern generalizes past Garmin
"The API works, but the account it needs to authenticate as has MFA and nobody's watching at 3am" is a common wall in integration work — banking exports, some CRMs, plenty of vendor portals. The fix is rarely code; it's an architecture decision: find or build a layer that a human authorizes once and interactively, which then exposes a normal, documented, machine-friendly API for everything after that. If you're stuck on an integration for exactly this reason, tell us what system you're blocked on and we can tell you honestly whether a relay like this is the right move.