Belschak.dev ← All projects
Case file 10·Everyday automation

strava-withings-sync.

Sync Strava activities and Withings health data (weight, sleep, heart rate) into Notion. Standard library Python only, no dependencies. The interesting part: it reconstructs per-activity heart rate from the watch's intraday HR stream by timestamp, because the two services will not merge it for you.


#What it is

A stateless CLI you run from cron: activities pulls new Strava activities into a Notion database with pace, splits, effective pace and weather; weight and sleep pull Withings data; hr-backfill sweeps history for activity rows without heart rate and reconstructs it; sync does all of it in one go. All syncs are idempotent, so re-running never duplicates rows. One-time browser OAuth per provider, tokens refresh automatically afterwards. MIT licensed.

#The itch

I record runs with my phone (GPS, distance, splits) and wear a watch that records heart rate. Two devices, two silos: the Strava activity has pace but no heart rate, because the phone has no HR sensor, and the watch's vendor integration uploads its own recording as a separate activity instead of attaching HR to the run I actually recorded. The only thing the two recordings share is time. So this tool joins them on time: it pulls the watch's intraday heart-rate samples from the Withings API and matches them to each activity's start and duration. Everything ends up in Notion, where I actually look at it.

#How it works

Two matching modes, depending on what is known. With a known start time (the live gate during activities): take the watch samples between start and end plus padding for clock skew, and write average, max, min and a time-in-zone distribution onto the Notion row. With an unknown start time (hr-backfill, for rows that only have a date and a duration): fetch the whole day of samples and slide a window of the activity's length across it. The window with the highest average HR is the workout, nearly always. A plausibility floor (default 120 bpm) rejects days where the best window still averages resting HR, which means the watch was not worn during the activity. Those rows are reported, not guessed.

$ python3 -m strava_withings_sync hr-backfill --since 2026-05-01
... hr-backfill: 3 candidate pages since 2026-05-01
... hr-backfill: patched 2026-05-04 8.2km avg=151 window=07:41-08:29
... hr-backfill: patched 2026-05-11 12.0km avg=154 window=09:02-10:07
... hr-backfill: 2026-05-18 5.0km best window avg=71 below floor 120
    (watch not worn during activity?)
... hr-backfill: done {'total': 3, 'patched': 2, 'no_data': 0,
    'implausible': 1, 'skipped': 0}

example runThe run above is an example, not live output.

The Withings workouts endpoint would make this trivial, but it is unavailable on the free API tier, and the daily activity aggregate mixes in samples from other sources. The intraday stream is the only reliable free-tier source, so the project is built on it.

Some design decisions the README defends at length: standard library only, on purpose, so the cron job has no dependency to break eight months in ("urllib is ugly and it is enough"). Rotating refresh tokens get atomic writes with persist-or-fail semantics, because both providers invalidate the refresh token on every refresh. And the incremental pointer never jumps to "now", because an activity uploaded late (dead phone battery, airplane mode) would otherwise fall through the gap forever. The matching, splits and zone math live in side-effect-free modules with 60 unit tests on synthetic data.

#In real use

The demo output in the README is a labelled example run. What the repo states plainly: this syncs a single athlete, and Notion is the only target. Matching quality depends on sampling density; in everyday mode the watch samples every few minutes, enough for a 40-minute run and marginal for a 10-minute one. Two workouts of similar length on one day can fool the sliding-window scan. There are no webhooks by design; the project stays a stateless CLI you can cron, and a large first backfill takes a few minutes because Notion allows roughly 3 requests per second. It is extracted from a personal pipeline in daily use and tested against that one setup (phone GPS plus one HR watch).

Version 1.0.0 shipped on 2026-08-02 with all sync commands, both matching modes and the rotating-token stores.

#Install

$ git clone https://github.com/belschak/strava-withings-sync.git
$ cd strava-withings-sync
$ cp .env.example .env

Requirements: Python 3.10+, nothing to pip-install. You create three integrations yourself (a Strava API application, a Withings developer application, a Notion internal integration; about 15 minutes), fill in .env, run authorize once per provider, then init-db and sync. Every command except authorize takes --dry-run.