# withings-sync: pushing a Withings scale reading into Garmin Connect

> withings-sync is a small Python tool that pulls measurements from the Withings API and forwards them to Garmin Connect, Trainer Road, or raw JSON output. It runs as a one-shot job under cron or supercronic, stores session tokens in a JSON file, and its current major release breaks compatibility because the underlying garth library was replaced.

**jaroslawhartman/withings-sync** — Synchronisation of Withings weight

- Repository: https://github.com/jaroslawhartman/withings-sync
- Stars: 697 · Forks: 138
- Language: Python
- License: MIT
- Published: 2026-10-21 · Updated: 2026-10-21 · Language: en
- Canonical page: https://hysenlabs.com/projects/jaroslawhartman-withings-sync

## Three destinations, one pull from the Withings API

The scope of withings-sync is small and stated plainly: it synchronises the Withings API to Garmin Connect, Trainer Road, and raw JSON output. That third destination is the one that tells you the design intent, because raw JSON is what a home automation system or a personal data pipeline would consume, and the keyword list in the package metadata names garmin, withings, sync, api, scale and smarthome.

The tool is described as a synchronisation of Withings weight, and the Withings API it talks to is the one that historically belonged to Nokia Health Body. The project description in the package metadata makes that lineage explicit, which is useful context if you are searching for the API under its older name.

The operation is a pull, not a push. The log output in the README shows the shape of a run: it refreshes the access token, then logs the measurement window it is about to fetch, then retrieves measurements, then attempts the upload and reports that there was nothing to upload for the date or period specified. The measurement window in the example spans a single day, from midnight to the end of day, which is the granularity the tool works at.

The package itself is a Poetry project named withings-sync with a single console script entry point, withings-sync, mapped to withings_sync.sync:main. The runtime dependencies are deliberately few: garminconnect, requests, lxml, python-dotenv and importlib-resources, with Python 3.11 or newer required.

## The first run needs a browser and a thirty second window

Installation by pip is a single command, and the interactive setup that follows is the part worth planning for:

```bash
$ pip install withings-sync
```

On the very first run there is no config file, and the tool says so plainly in the log before it tells you what to do next. It reports that it cannot read the config file at ~/.withings_user.json, warns that user interaction is needed to get an authentication code from Withings, and then prints an authorisation URL for account.withings.com along with a warning that you have 30 seconds before the token expires.

The mechanism is a standard OAuth authorisation code flow with a redirect into a static page hosted in the repository's contrib directory, and the scope requested is user.metrics. You open the URL, Withings shows a code, and you paste it back at the prompt.

The logs then show the token exchange in sequence: get access token, then refresh access token, then the fetch of measurements for the day. The README is explicit that this is one-time activity and that it does not need to be repeated, because the tokens are saved to ~/.withings_user.json and subsequent runs start from the refresh step instead of the browser step. That file is the whole security story of this tool, and it is worth understanding before you schedule the job.

## Docker Compose with a scheduler, not a daemon

The containerised install follows a deliberate pattern: one container runs one job and then exits, with an external scheduler doing the work of deciding when. The README notes that this approach relies on an external scheduler, for example cron on the host operating system, to manage job execution.

The layout expects a .env file, a docker-compose.yml and a config directory that is created automatically. The environment file holds the timezone, the stack path and the Garmin credentials:

```bash
TZ=Europe/Kyiv
STACK_PATH=/home/your_user/your_stack_name
GARMIN_USERNAME="your.name@domain.ext"
GARMIN_PASSWORD="YourPasswordHere"
```

The compose file is where the interesting decision sits, namely the --config-folder argument:

```yaml
services:
  withings-sync:
    image: ghcr.io/jaroslawhartman/withings-sync:latest
    container_name: withings-sync
    stdin_open: true # docker run -i
    tty: true        # docker run -t
    environment:
      - TZ=${TZ:?err}
      - GARMIN_USERNAME=${GARMIN_USERNAME:?err}
      - GARMIN_PASSWORD=${GARMIN_PASSWORD:?err}
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - ${STACK_PATH:?err}/config/withings-sync:/config
    command: ["--config-folder", "/config"]
    restart: unless-stopped
```

The README explains the benefit of --config-folder in three bullets: it stores both the Withings and the Garmin session files in one directory, it removes the need to mount individual files, and it makes backup and migration of the configuration easier. That is a real operational improvement over bind-mounting two separate token files.

The one-shot model is also why stdin_open and tty are enabled. The interactive authorisation step needs a terminal, and the container has to be able to prompt you on the first run even though the normal case is unattended.

## The image runs as a non-root user with its own supercronic

The Dockerfile is short but shows how seriously the project treats the container. It starts from python:3.11-alpine, installs supercronic, and then does something that most Dockerfiles skip: it looks for an existing user and group with the target UID and GID, which default to 1000, and deletes them if they exist, so that the container's service user can be bound to the UID of the host user running it. That avoids the classic root-owned-file problem when a bind-mounted config directory is written to from inside the container.

The service user and group are then created with the project name, the container switches to that user, and the working directory is set to a directory under the user's home. The environment sets up Poetry inside the project directory with the virtualenv created in project mode, and the dependency install is done in two steps: first the lock file and metadata with --without dev --no-root, then the package itself.

The entry point is poetry run withings-sync, so the same command is used whether the container is started by hand or by the scheduler. The alpine base plus the absence of a package manager layer in the final image is what keeps the footprint small, though the image still carries a full Python toolchain and Poetry rather than a compiled binary.

One detail is inherited from the compose example rather than the image: the service command overrides the entry point arguments with --config-folder, so the config directory argument is supplied at run time by the compose file.

## Upgrade means a full login cycle again

The release notes carry a caution block at the top of the README, and it is the single most important thing to read before upgrading. The current release introduces breaking changes that users need to be aware of before upgrading or using it, because the underlying garth library is deprecated and replaced, so it needs a full login cycle again.

That sentence is worth parsing carefully. The authentication path through Withings was not cosmetic, it was structural, and when the library underneath it was replaced the stored token state could not simply be carried forward. A full login cycle means the interactive browser step from the first-run section, completed again with a new authorisation code, and the old session file replaced rather than reused.

The release that introduced this is v6.1.0, and its title names the changes: fix Trainerroad, Withings API Rate Limit and OAuth refresh. The mention of a rate limit fix in the same release is another signal worth noting, because it implies the tool is subject to the Withings API's request throttling and that the upstream API's behaviour has changed under it. The previous release, v6.0.2, is described as a migration to python-garminconnect, which is the library the current dependency list points at.

The last push was on 2026-10-15, the same day as the v6.1.0 release, and the project is not archived. The v6.0.x line from April shows that the Garmin side has been rewritten once already in 2026 alone.

## A personal tool with a test suite and a maintained upstream

The repository is small and organised like a maintained project rather than a script thrown on a gist. There is a tests/ directory, a contrib/ directory that holds the static page used for the OAuth redirect, a Dockerfile, a poetry.lock for reproducible installs, a MANIFEST.in, and a .env.example containing only the two Garmin credential placeholders. The pyproject also carries author and maintainer metadata, and the dev dependency group is black for formatting and pylint for linting.

The dependency choices explain a good deal about how the tool works. garminconnect is the community library that talks to Garmin Connect, and it replaced the older approach in v6.0.2, which is the same kind of churn the garth replacement represents on the Withings side. lxml handles the XML that some of these APIs still return, requests is the HTTP layer, and python-dotenv handles the .env file that the Docker setup relies on.

The classification in the package metadata places the tool under Utilities and Home Automation, which is an accurate self-description. It is a bridge between a consumer scale and a fitness platform, it holds a long-lived credential, and it is scheduled by something outside itself. That is a good fit for a personal home server and a poor fit for anything that needs an SLA, and the project does not pretend otherwise: there is a Q&A link in the README for questions, and the issue tracker is where defects would go.

## Conclusion

Adopt withings-sync if you already own a Withings scale, want its measurements in Garmin Connect or Trainer Road without manual entry, and are willing to run a scheduled job that stores your Withings and Garmin session files on disk. Do not adopt it on the assumption that upgrading is painless, because the current release states that the underlying garth library is deprecated and replaced and that a full login cycle is needed again, and do not run it against a shared or multi-user host, because the credentials are handled through plain files and environment variables. Before you commit, install the package with pip, run it once interactively to complete the Withings authorisation within the thirty second token window, and confirm that ~/.withings_user.json is written with permissions you are comfortable with, since that file holds the tokens that let it fetch your measurements.

## FAQ

### What does withings-sync do and where does it send the data?

It synchronises the Withings API to Garmin Connect, Trainer Road and raw JSON output. It pulls measurements for a given period and forwards them, and the package metadata classifies it under Utilities and Home Automation.

### How do I install withings-sync and authorise it against Withings?

Install it with pip install withings-sync, then run withings-sync once. The first run prints an authorisation URL for account.withings.com and asks you to paste back the authentication code within 30 seconds, then saves the tokens to ~/.withings_user.json so later runs only refresh the token.

### Does upgrading withings-sync break my existing login?

Yes, for the current release. The README states that the release introduces breaking changes, that the underlying garth library is deprecated and replaced, and that a full login cycle is needed again, so the browser authorisation step has to be repeated.

### How do I run withings-sync on a schedule with Docker?

The documented approach runs a single container for one job and then exits, with cron on the host as the external scheduler. The compose file mounts a config directory, passes --config-folder /config so both the Withings and Garmin session files live in one place, and sets the Garmin credentials through environment variables.

### What are the requirements for withings-sync?

Python 3.11 or newer is required, along with garminconnect, requests, lxml, python-dotenv and importlib-resources. The package is licensed under MIT, and the console script is installed as withings-sync.

## Sources

- [Issues](https://github.com/jaroslawhartman/withings-sync/issues)
- [jaroslawhartman/withings-sync on GitHub](https://github.com/jaroslawhartman/withings-sync)
- [License: MIT](https://github.com/jaroslawhartman/withings-sync/blob/master/LICENSE)
- [README](https://github.com/jaroslawhartman/withings-sync/blob/master/README.md)
- [Releases](https://github.com/jaroslawhartman/withings-sync/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/jaroslawhartman-withings-sync
