Skip to content

Exporters

BLE Scale Sync exports body composition data to 11 targets. The setup wizard walks you through exporter selection, configuration, and connectivity testing.

Exporters are configured in global_exporters (shared by all users). For multi-user setups with separate accounts, see Per-User Exporters. All enabled exporters run in parallel; the process reports an error only if every exporter fails.

TargetDescription
Garmin ConnectAutomatic body composition upload, no phone app needed
MQTTHome Assistant auto-discovery with 10 sensors, LWT
InfluxDBTime-series database (v2 and v3)
WebhookAny HTTP endpoint (n8n, Make, Zapier, custom APIs)
NtfyPush notifications to phone/desktop
TelegramSend measurement notifications to a Telegram chat
File (CSV/JSONL)Append readings to a local file
StravaUpdate weight in your Strava athlete profile
Intervals.icuPush weight + body fat to Intervals.icu wellness
RunalyzePush weight + body composition to Runalyze metrics
WgerPush weight + body composition to a Wger instance

Garmin Connect

Automatic body composition upload to Garmin Connect, no phone app needed. Uses a Python subprocess with cached authentication tokens.

FieldRequiredDefaultDescription
emailYes(none)Garmin account email
passwordYes(none)Garmin account password
token_dirNo~/.garmin_tokensDirectory for cached auth tokens
weight_onlyNofalseUpload the weight alone, leaving every derived metric unset
upload_timeout_secNo180Seconds one upload attempt may take before it is killed (10-900). Three attempts are made, with no wait between them
yaml
global_exporters:
  - type: garmin
    email: '${GARMIN_EMAIL}'
    password: '${GARMIN_PASSWORD}'

Slow Garmin days

Each upload attempt is killed after upload_timeout_sec seconds and retried up to three times. The default of 180 s covers a Garmin Connect that is merely slow; if you see Python uploader timed out three times for a measurement that uploads fine by hand afterwards, raise it (the maximum is 900).

The cost of a higher value is only paid when Garmin is actually failing: three attempts run back to back with no wait between them, so a dead Garmin takes three times the timeout to give up, and in continuous mode the next scan cycle and the ntfy/Telegram summary wait that long too.

yaml
global_exporters:
  - type: garmin
    email: '${GARMIN_EMAIL}'
    password: '${GARMIN_PASSWORD}'
    upload_timeout_sec: 300

Weight only

Set weight_only: true to record just the weight and leave BMI, body fat, water, bone mass, muscle mass, visceral fat, physique rating and metabolic age unset in Garmin Connect. Useful when you trust the scale's weight but not its bioimpedance estimates. In continuous mode the config watcher picks it up on the next scan cycle, so no restart is needed (unless you have set runtime.watch_config: false). It does not affect any other exporter.

Note that Garmin Connect derives its own BMI from the weight and the height in your Garmin profile, so a BMI figure may still appear on the entry - it just will not be the scale's.

yaml
global_exporters:
  - type: garmin
    email: '${GARMIN_EMAIL}'
    password: '${GARMIN_PASSWORD}'
    weight_only: true

Authentication

The setup wizard handles Garmin authentication automatically. You only need to authenticate once; tokens are cached and reused. To re-authenticate manually:

Standalone (Node.js):

bash
ble-scale-sync setup-garmin   # from a clone: npm run setup-garmin

Docker (single user with env vars):

bash
docker run --rm -it \
  -v ./config.yaml:/app/config.yaml \
  -v ./garmin-tokens:/app/garmin-tokens \
  -e GARMIN_EMAIL \
  -e GARMIN_PASSWORD \
  ghcr.io/kristianp26/ble-scale-sync:latest setup-garmin

Docker (specific user from config.yaml):

bash
docker run --rm -it \
  -v ./config.yaml:/app/config.yaml \
  -v ./garmin-tokens-alice:/app/garmin-tokens-alice \
  -e GARMIN_EMAIL -e GARMIN_PASSWORD \
  ghcr.io/kristianp26/ble-scale-sync:latest setup-garmin --user Alice

Docker (all users from config.yaml):

bash
docker run --rm -it \
  -v ./config.yaml:/app/config.yaml \
  -v ./garmin-tokens-alice:/app/garmin-tokens-alice \
  -v ./garmin-tokens-bob:/app/garmin-tokens-bob \
  -e GARMIN_EMAIL -e GARMIN_PASSWORD \
  ghcr.io/kristianp26/ble-scale-sync:latest setup-garmin --all-users

IP blocking

Garmin may block requests from cloud/VPN IPs. If authentication fails, try from a different network, then copy the token directory to your target machine.

Upgrading from v1.8.0 or earlier

v1.8.1 bumps garminconnect to 0.3.x, which replaced the old garth-based OAuth files (oauth1_token.json, oauth2_token.json) with a single garmin_tokens.json. Existing tokens are incompatible. Re-run ble-scale-sync setup-garmin (npm run setup-garmin from a clone); the script auto-removes the legacy files before writing the new format.

MQTT

Publishes body composition as JSON to an MQTT broker. Home Assistant auto-discovery is enabled by default; all 10 metrics appear as sensors grouped under a single device, with availability tracking (LWT) and display precision per metric.

Home Assistant users

If you run Home Assistant OS or Supervised, the Home Assistant Add-on auto-detects the Mosquitto broker through the Supervisor API, so you do not need to wire MQTT manually.

FieldRequiredDefaultDescription
broker_urlYes(none)mqtt://host:1883 or mqtts:// for TLS
topicNoscale/body-compositionPublish topic
qosNo1QoS level (0, 1, or 2)
retainNotrueRetain last message
usernameNo(none)Broker auth username
passwordNo(none)Broker auth password
client_idNoble-scale-syncMQTT client identifier
ha_discoveryNotrueHome Assistant auto-discovery
ha_device_nameNoBLE ScaleDevice name in Home Assistant
yaml
global_exporters:
  - type: mqtt
    broker_url: 'mqtts://broker.example.com:8883'
    username: myuser
    password: '${MQTT_PASSWORD}'

Webhook

Sends body composition as JSON to any HTTP endpoint. Works with n8n, Make, Zapier, or custom APIs.

FieldRequiredDefaultDescription
urlYes(none)Target URL
methodNoPOSTHTTP method
headersNo(none)Custom headers (YAML object)
timeoutNo10000Request timeout in ms
yaml
global_exporters:
  - type: webhook
    url: 'https://example.com/hook'
    headers:
      X-Api-Key: '${WEBHOOK_API_KEY}'

InfluxDB

Writes metrics using line protocol. Float fields use 2 decimal places, integer fields use i suffix.

Works with InfluxDB v2 and v3. v3 keeps a v2-compatible /api/v2/write endpoint that accepts the same line protocol, the same Token authorization scheme and the same 204 response, so one exporter covers both.

FieldRequiredDefaultDescription
urlYes(none)InfluxDB server URL
tokenYes(none)API token with write access
orgv2 only(none)Organization name. Omit on v3, which has none
bucketYes(none)Bucket name on v2, database name on v3
measurementNobody_compositionMeasurement name

InfluxDB v2:

yaml
global_exporters:
  - type: influxdb
    url: 'http://localhost:8086'
    token: '${INFLUXDB_TOKEN}'
    org: my-org
    bucket: my-bucket

InfluxDB v3 (Core, Enterprise, Cloud), where org is left out and bucket names the database:

yaml
global_exporters:
  - type: influxdb
    url: 'http://localhost:8181'
    token: '${INFLUXDB_TOKEN}'
    bucket: my-database

Setting org on v3 is harmless, since the server ignores the parameter, but leaving it out keeps the config honest about what the target actually has.

Ntfy

Push notifications to phone/desktop via ntfy. Works with ntfy.sh or self-hosted instances.

FieldRequiredDefaultDescription
urlNohttps://ntfy.shNtfy server URL
topicYes(none)Topic name
titleNoScale MeasurementNotification title
priorityNo3Priority (1 to 5)
tokenNo(none)Bearer token auth
usernameNo(none)Basic auth username
passwordNo(none)Basic auth password
report_exportsNofalseAppend the other exporters' results
yaml
global_exporters:
  - type: ntfy
    topic: my-scale
    priority: 4

Weight, muscle and bone follow scale.weight_unit.

With report_exports: true the notification is sent after the other exporters finish and ends with one line per non-reporting exporter, ✅ garmin or ❌ garmin: <error>, so a failed sync is visible on the phone. Two notifiers with the flag set do not report on each other. The notification arrives once the slowest exporter (and its retries) is done; with Garmin that can be up to three minutes when its uploader times out and retries. Error text is forwarded as-is (truncated to 120 characters), so a public ntfy topic will carry it.

Telegram

Send a measurement notification to a Telegram chat via a bot. Create a bot with @BotFather to get a bot token, then start a chat with your bot (or add it to a group/channel) so it can message you.

FieldRequiredDefaultDescription
bot_tokenYes(none)Bot token from @BotFather
chat_idYes(none)Target chat ID (numeric) or @channelusername
titleNoScale MeasurementFirst line of the message
silentNofalseDeliver without a notification sound
report_exportsNofalseAppend the other exporters' results
yaml
global_exporters:
  - type: telegram
    bot_token: '${TELEGRAM_BOT_TOKEN}'
    chat_id: '987654321'
    title: Scale Measurement
    silent: false

The message is sent as plain text. Weight, muscle and bone follow scale.weight_unit. report_exports works as for Ntfy. In multi-user setups the user's name is prepended as [Name]. Historical readings replayed from a scale's offline cache are skipped (a notification for an old measurement is not meaningful).

Finding your chat ID

Message your bot once, then open https://api.telegram.org/bot<token>/getUpdates in a browser - the chat.id field holds your chat ID. For groups, add the bot to the group first.

File (CSV/JSONL)

Append each reading to a local CSV or JSONL file. Useful for simple logging without external services.

FieldRequiredDefaultDescription
file_pathYesPath to the output file
formatNocsvcsv or jsonl
yaml
global_exporters:
  - type: file
    file_path: './measurements.csv'
    format: csv

CSV files get an automatic header row on first write. JSONL files append one JSON object per line.

Docker

Mount a volume so the file persists across container restarts:

yaml
volumes:
  - scale-data:/app/data
# config.yaml: file_path: './data/measurements.csv'

Strava

Update your weight in the Strava athlete profile. Requires a Strava API application.

FieldRequiredDefaultDescription
client_idYesStrava API application client ID
client_secretYesStrava API application client secret
token_dirNo./strava-tokensDirectory for cached OAuth tokens
yaml
users:
  - name: Alice
    exporters:
      - type: strava
        client_id: '${STRAVA_CLIENT_ID}'
        client_secret: '${STRAVA_CLIENT_SECRET}'

Creating a Strava API Application

  1. Go to strava.com/settings/api
  2. Upload an Application Icon (required before you can save the form)
  3. Fill in the application details:
    • Application Name: anything you like (e.g. BLE Scale Sync)
    • Category: choose any
    • Website: can be anything (e.g. https://github.com/KristianP26/ble-scale-sync)
    • Authorization Callback Domain: set to localhost (the OAuth flow redirects here, but the page does not need to load)
  4. Save and copy the Client ID and Client Secret

Callback Domain

The Authorization Callback Domain must be set to localhost. During the OAuth flow, Strava redirects to http://localhost?code=XXXX. The page will not load (nothing is listening), but you only need to copy the code parameter from the URL bar.

Authentication

After adding the Strava exporter to your config, run the setup script to authorize:

Standalone (Node.js):

bash
ble-scale-sync setup-strava   # from a clone: npm run setup-strava

Docker:

bash
docker run --rm -it \
  -v ./config.yaml:/app/config.yaml \
  -v strava-tokens:/app/strava-tokens \
  ghcr.io/kristianp26/ble-scale-sync:latest setup-strava

The script prints a browser URL for Strava authorization. After authorizing, copy the code parameter from the redirect URL and paste it back. Tokens are cached and automatically refreshed.

Intervals.icu

Push weight and body fat to your Intervals.icu wellness data. Intervals.icu is a free training-analytics platform - a natural fit alongside the Garmin and Strava exporters.

FieldRequiredDefaultDescription
athlete_idYes(none)Intervals.icu athlete ID (e.g. i123456)
api_keyYes(none)API key from Intervals.icu Settings → Developer
yaml
users:
  - name: Alice
    exporters:
      - type: intervals
        athlete_id: i123456
        api_key: '${INTERVALS_API_KEY}'

Authentication uses HTTP Basic with the API key - no OAuth flow. Find both values on the Intervals.icu Settings → Developer page. The reading updates the wellness record for its day (weight + bodyFat); historical readings replayed from a scale's offline cache land on their original date.

Runalyze

Push weight and body composition to Runalyze, an endurance-training analytics platform, as health metrics.

FieldRequiredDefaultDescription
tokenYes(none)Personal API token from Runalyze Settings → Personal API
yaml
users:
  - name: Alice
    exporters:
      - type: runalyze
        token: '${RUNALYZE_TOKEN}'

Authentication uses the Runalyze Personal API token (sent in the token header), no OAuth flow. Generate it at runalyze.com/settings/personal-api; note that Runalyze tokens require an expiry date, so the token has to be regenerated when it lapses.

The reading is sent to the bodyComposition metric with an exact timestamp, so historical readings replayed from a scale's offline cache land on their original date and time. Weight, body fat and body water map directly; muscle and bone are converted from mass to a percentage of body weight (Runalyze stores those as percentages). Metrics that an adapter could not measure are omitted.

Wger

Push weight and body composition to Wger, the open-source self-hosted workout and weight manager. A natural fit for the self-hosting audience, and it matches what openScale-sync already supports.

FieldRequiredDefaultDescription
base_urlYes(none)Wger instance URL, e.g. https://wger.de or your self-hosted host
tokenYes(none)Permanent API key from <base_url>/en/user/api-key
sync_measurementsNotrueAlso push body fat, water, muscle, bone as custom measurements
yaml
users:
  - name: Alice
    exporters:
      - type: wger
        base_url: https://wger.de
        token: '${WGER_TOKEN}'
        sync_measurements: true

Authentication uses a permanent API key (sent as Authorization: Token <key>), no OAuth flow. Generate it on the Wger account settings API page. Weight is written to a weight entry on the reading's calendar day, so historical readings replayed from a scale's offline cache land on their original date. With sync_measurements enabled, body fat and water (percent) and muscle and bone (kg) are written as Wger custom measurements; the matching categories are created automatically on first use and reused afterwards. Measurement failures are logged but do not block the weight sync.

Secrets

Use ${ENV_VAR} references in YAML for passwords and tokens. The variable must be defined in the environment or in a .env file:

yaml
global_exporters:
  - type: garmin
    email: '${GARMIN_EMAIL}'
    password: '${GARMIN_PASSWORD}'

See Configuration: Environment Variables for details.

A boolean field must spell a boolean

An ${ENV_VAR} reference is resolved to a string before the exporter reads it, so a true/false field only accepts a value that reads as one: true, yes, 1, on, or false, no, 0, off, or empty. Anything else stops that exporter from being built, with an error naming the field, rather than being guessed at in one direction or the other.

This applies to weight_only (garmin), retain and ha_discovery (mqtt), silent (telegram), report_exports (ntfy and telegram) and sync_measurements (wger). So MQTT_RETAIN=maybe is an error, not a default.

Historical readings

Some scales keep measurements taken while nothing was listening and replay them on the next connection. Two adapters currently pass that recorded time on: Beurer BF720 (and the BF105, BF500, BF788 and BF950 it serves) and Renpho ES-26BB. A reading carrying a time is treated differently from a live one.

Several other scales replay a cache without a usable time. Salter is the explicit case: its stored records are delivered as ordinary readings on purpose, because a dated reading it could not date correctly would be buffered rather than exported.

A reading with a timestamp is sent only to exporters that can record it at that time:

ExporterAccepts a backdated reading
fileYes
garminYes
influxdbYes
intervalsYes
runalyzeYes
wgerYes
mqttNo
webhookNo
ntfyNo
stravaNo
telegramNo

The five that say No have no way to express "this happened on Tuesday": an MQTT sensor state and a push notification are both about now, so replaying a three-day-old weigh-in through them would put a stale number in front of you as if it had just been measured.

So a scale that replays its cache fills in your Garmin and InfluxDB history while Home Assistant shows only the live weigh-ins. The log says so each time it happens:

Historical reading (2026-09-07T06:12:44.000Z): skipping non-back-date exporters [mqtt, ntfy]

Every reading taken while the app is running is a live reading and goes to every configured exporter.

Healthchecks

At startup, exporters are tested for connectivity. Failures are logged as warnings but don't block the scan.

ExporterMethod
MQTTConnect + disconnect
WebhookHEAD request
InfluxDB/health endpoint, with token
Ntfy/v1/health endpoint
TelegramgetChat endpoint
Intervals.icuGET wellness record
RunalyzeGET bodyComposition metric
WgerGET userprofile record
GarminNone (Python subprocess)
FileDirectory writable check
StravaNone (avoid API rate limits)

Released under the GPL-3.0 License.