Skip to content

Polar Flow

Dreeve imports anything dropped into its watch/ folder. Getting your Polar training sessions into that folder is what the Polar connector does: it periodically lists new exercises, downloads the original .fit files and drops them in. No manual exports.

Polar Flow → connector → watch/ → Dreeve imports it

The connector is a separate container from its own repository, dreeveapp/dreeve-polar-connector. It is not part of Dreeve itself, it only writes files into the folder you already mount.

Create a client at admin.polaraccesslink.com. You get a client id and a client secret, and you register one redirect URL — where Polar sends your browser back after you approve access. Use the address this connector will be reachable at, plus /callback:

http://<this-host>:8080/callback

If Polar later refuses that URL, see When Polar refuses the redirect.

Add this alongside the app and daemon services in your docker-compose.yml:

docker-compose.yml
# Pulls training sessions out of Polar Flow into the watch folder.
polar-connector:
image: ghcr.io/dreeveapp/dreeve-polar-connector:latest
container_name: dreeve-polar-connector
restart: unless-stopped
volumes:
# The same ./watch folder the app and daemon mount.
- ./watch:/watch
- ./polar/state:/state
- ./polar/tokens:/tokens
ports:
# Serves /authorize, /status and Polar's webhook deliveries.
- '8080:8080'
env_file: ./.env

Add to your .env:

.env
POLAR_CLIENT_ID=your-client-id
POLAR_CLIENT_SECRET=your-client-secret
# Where this connector is reachable in a browser. Used to build the redirect URI,
# which must match what you registered for the client.
PUBLIC_URL=http://<this-host>:8080

Start the container, then open http://<this-host>:8080/authorize in a browser and approve access:

Terminal window
> docker compose up -d polar-connector

Until you do, the container reports unhealthy on purpose — an unauthorized connector is otherwise silent, and a silent connector looks exactly like a working one with nothing to do. GET /status tells you which URL to open.

The token Polar issues does not expire. Keep the ./polar/tokens volume and you never authorize again.

Running headless, with HTTP_ADDR=off and no page to open? Use this instead, which prints the URL and reads the code back from your terminal:

Terminal window
> docker compose run --rm polar-connector login
Terminal window
> docker compose up -d
> docker compose logs -f polar-connector

A cycle runs immediately, then one every 15 minutes. Files land in the watch folder as <exerciseId>.fit, and the daemon container will import them, exactly as if you had dropped them there yourself.

The AccessLink API returns exercises uploaded to Flow within the last 30 days, and only ones uploaded after the moment you authorized this client. There is no endpoint for anything older, so this connector does not pretend to backfill. Two consequences worth knowing before you start:

  • Authorize before your next ride. Anything uploaded while nobody has authorized is never offered to the connector, even within the 30-day window.
  • Old history is a manual job. Export it from Polar Flow and drop the files into the watch folder yourself. If you have already done that, set SINCE=now so the connector does not deliver the same sessions a second time.

A first run is up to a month of exercises, and asking for all of them at once is the most reliable way to get rate-limited. So a cycle downloads at most MAX_DOWNLOADS_PER_CYCLE exercises (25 by default) and picks up where it left off next time. At the default 15-minute interval that is around 2400 a day, which clears any 30-day window comfortably within one. The remainder shows up as backlog.

Fetch the status by running:

Terminal window
> docker compose exec polar-connector dreeve-polar-connector status
KeyMeaning
healthyfalse while nobody has authorized, and once three POLL_INTERVALs have passed without a completed cycle.
startedAtWhen this container started.
cyclesCycles attempted since that start, successful or not.
lastSuccessfulSyncEnd of the last cycle that completed. null until the first one does.
nextRunAtWhen the next cycle is due, jitter and backoff included.
backoffSecondsHow long the connector is currently backing off after a rate limit. 0 when all is well.
authorizationok, required (nobody has authorized yet), revoked (Polar refused the stored token) or consent-required.
authorizeUrlThe URL to open, while there is something to authorize. null once authorized.
authorizationErrorWhat Polar said, when the state is anything but ok.
polarUserThe Polar user id the stored token belongs to.
lastErrorThe last failure message, cleared by the next successful cycle.
lastCycleWhat the last cycle did. null before the first one.
lastCycle.listedExercises Polar returned for the window.
lastCycle.deliveredFiles written to the watch folder this cycle. Capped by MAX_DOWNLOADS_PER_CYCLE.
lastCycle.failedDownloads that went wrong and will be retried.
lastCycle.skippedExercises older than SINCE, or filtered out by SPORTS, so deliberately not fetched.
lastCycle.withoutFileExercises Polar has no file for, in any format.
lastCycle.backlogSame as the top-level backlog.
backlogExercises still owed a download, whatever cycle they were listed in. This is the number to watch during a first run.
exercisesThe whole ledger counted by state: pending, delivered, skipped, failed, no-file.
webhookconfigured, active, its id and lastEventAt. All quiet unless WEBHOOK_URL is set.
rateLimitWhat Polar last reported of your budget: shortUsage, shortLimit, longUsage, longLimit, resetSeconds.
VariableDefaultWhat it does
POLAR_CLIENT_ID-Required. From admin.polaraccesslink.com. Also accepts POLAR_CLIENT_ID_FILE for Docker secrets.
POLAR_CLIENT_SECRET-Required. Also accepts POLAR_CLIENT_SECRET_FILE.
PUBLIC_URL-Where this connector is reachable in a browser. The redirect URI becomes <PUBLIC_URL>/callback. Leave empty when your client has exactly one registered URL — Polar then picks it.
REDIRECT_URI-Sent verbatim, overriding the PUBLIC_URL default. See When Polar refuses the redirect.
WATCH_DIR/watchDreeve’s watch folder.
STATE_DIR/stateWhere the ledger is stored. Mount it as a volume.
POLAR_TOKENS/tokensWhere the access token and webhook signing key are stored. Mount it as a volume.
SINCE-30dHow far back to reach on the first run. A date (2026-01-01), an ISO instant, a relative offset (-30d, 720h) or now. Resolved once, then remembered. -30d is everything Polar has.
SPORTS-Only these sports, comma separated (running,cycling). Matched against Polar’s sport or its detailed sport name. Empty means everything.
FALLBACK_FORMATtcxtcx, gpx or none, for exercises that have no .fit file.
ON_CONFLICTskipskip or overwrite, when the file is already in the watch folder.
MAX_ATTEMPTS5How often an exercise may fail before it is left alone.
POLL_INTERVAL900Seconds between cycles. Polar’s budget is 520 requests per 15 minutes per user; a cycle at this pace uses about 2% of it.
POLL_JITTER_PCT10Randomises the interval by ±this much, so every deployment of this image does not hit Polar on the same second.
MAX_DOWNLOADS_PER_CYCLE25The per-cycle cap. See above.
DOWNLOAD_DELAY_SECONDS1Pause between downloads.
MAX_BACKOFF_SECONDS21600Cap on the backoff after a rate limit (6 hours).
WEBHOOK_URL-A publicly reachable HTTPS URL ending in /webhook. See Instant delivery via webhook.
HTTP_ADDR0.0.0.0:8080Serves /healthz, /status, /authorize, /callback and /webhook. off disables the server, moving authorization to the login command and the healthcheck to a heartbeat file in STATE_DIR.
MAX_CYCLES00 runs forever; anything else runs that many cycles and exits.
DRY_RUNfalseList what would be downloaded, download nothing, leave the ledger untouched.
LOG_LEVELinfodebug, info, warning, error or critical.
LOG_FORMATtexttext or json.
PUID / PGID-Own the delivered files as this user. Set them to the same values Dreeve runs as.
UMASK / TZ-As usual.
Terminal window
# Authorize from a terminal, when there is no page to open. Once, interactively.
> docker compose run --rm polar-connector login
# Run a single cycle and exit.
> docker compose run --rm polar-connector sync-once
# Show what it would fetch, without downloading anything.
> docker compose run --rm polar-connector sync-once --dry-run
# Ask a running connector what it is doing.
> docker compose exec polar-connector dreeve-polar-connector status
# Manage the webhook for this client.
> docker compose run --rm polar-connector webhook register
> docker compose run --rm polar-connector webhook show
> docker compose run --rm polar-connector webhook delete

Set WEBHOOK_URL to a publicly reachable HTTPS URL ending in /webhook and Polar notifies this connector the moment a session is uploaded, cutting latency from POLL_INTERVAL to seconds. It is registered for you on startup, and re-activated if Polar has deactivated it.

Polling stays on regardless, so a webhook that quietly stops working costs you latency and never data. HTTP_ADDR has to stay on — Polar posts to this connector’s own server, and Polar deactivates a webhook after seven days of failed deliveries.

"authorization": "revoked" means Polar has refused the stored token, and the connector stops asking rather than hammering an endpoint that is saying no. Open /authorize again; the next cycle picks the new token up on its own, with no restart.

"authorization": "required" means nobody has authorized yet, or the token volume was lost. Same fix, same URL — authorizeUrl in /status spells it out.

A rising backlog with backoffSeconds above 0 is a rate limit, and it resolves itself. Check rateLimit to see how much of your budget Polar thinks you have used.

Oops, something went wrong somewhere along the way.

That page means Polar refused the request before showing a consent screen, and the usual reason is a redirect_uri it does not recognise for your client. Its admin page accepts a path, but the authorization endpoint has been observed honouring only the bare origin for the same client.

Polar validates the redirect before authentication, so you can test candidates without signing in — 303 means accepted, 200 means refused:

Terminal window
> CLIENT_ID=<your client id>
> REDIRECT=$(python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=""))' 'http://your-host:8080/callback')
> curl -s -o /dev/null -w '%{http_code}\n' \
"https://flow.polar.com/oauth2/authorization?response_type=code&client_id=${CLIENT_ID}&redirect_uri=${REDIRECT}"

Set REDIRECT_URI to whichever string Polar accepts; it is sent verbatim, trailing slash included. A bare origin works because this connector answers the callback at / as well as at /callback. Omitting both REDIRECT_URI and PUBLIC_URL also works when the client has exactly one registered URL — Polar then picks it, and the code arrives at whichever of the two paths that URL points to.