Skip to content

Developing an Obsidian Plugin for Garmin Connect

An open-source plugin that syncs Garmin Connect into your vault as note properties, with a dashboard on top — on the phone as well as the desktop.

Obsidian is where a lot of us keep notes. Garmin Connect is where a watch deposits everything it measured about you. The two have nothing to do with each other, which is the problem: the numbers describing your own body sit in an app you can read but cannot query.

So we built a plugin that moves them. It signs in to Garmin Connect, writes each day's metrics into your vault as note properties, and draws a dashboard on top of them. It is open source, and it runs on mobile as well as on the desktop — which turned out to be the hard part.

Three things stood in the way.

Garmin has no public API

Garmin's developer programme exists, but it is aimed at established businesses: you apply as a company, not as someone who would like their own step count. For an individual there is no key, no OAuth app, no documented endpoint.

What there is instead is a mobile app that authenticates against sso.garmin.com and then calls the same API the website does. cyberjunky/python-garminconnect is the reference implementation of that flow, and since its rewrite it signs in the way the app does: a JSON POST to /mobile/api/login, then a service-ticket exchange for OAuth2 bearer tokens. No OAuth1, no request signing. It ports to TypeScript cleanly.

The part that nearly killed the project was the front door. python-garminconnect installs curl_cffi to forge TLS fingerprints, and sleeps ten to twenty seconds before a login POST so Cloudflare's WAF does not score the burst as a bot. Inside Obsidian you get requestUrl — Electron's network stack on the desktop, the operating system's HTTP client on mobile — and you do not get to choose your fingerprint.

So before writing anything else, we measured it. A probe asks a TLS echo service what this platform looks like on the wire, then attempts a real sign-in.

PlatformHTTP stackJA4 prefixLogin POST
DesktopElectront13d1516h2_…200 SUCCESSFUL
iOSOS HTTP clientt13d2013h2_…200 SUCCESSFUL
Measured 12 September 2026. Two unrelated TLS fingerprints, both accepted.

Both got through, including the desktop's — which is Chromium TLS carrying an iPhone User-Agent, an obvious mismatch Cloudflare declined to punish. Garmin is not enforcing TLS fingerprinting on /mobile/api/*. The human-facing sign-in page at /portal/sso/en-US/sign-in, meanwhile, returns a 403 challenge even to plain curl. The two paths live in different protection buckets.

That is a server-side policy, not a promise. Garmin can tighten it any day and there would be no workaround from inside Obsidian, so an edge refusal is surfaced as its own error rather than a generic "sync failed".

It has to run on a phone

Obsidian on a phone is a WebView. No https module, no BrowserWindow, no tough-cookie, no axios. Anything reaching for them loads fine on your laptop and throws on your phone, which is where it is hardest to debug.

The prior art is Garmin Health Sync, which gave us the idea in the first place. It authenticates through an Electron BrowserWindow, so it ships as isDesktopOnly: true. Mobile was the gap.

The fix is a seam rather than a library. HTTP is injected: the Garmin code depends on a one-method interface, and three things implement it — requestUrl in the plugin, fetch in a Node harness, and a fixture transport that replays recorded responses in tests. The Garmin logic never learns which one it has.

ts
export interface HttpClient {
  request(req: HttpRequest): Promise<HttpResponse>;
}

The tempting alternative — build node-garminconnect first, consume it from the plugin second — fails for exactly this reason. A Node library bakes in Node assumptions you then cannot take back out for a WebView. Starting transport-agnostic gets you both, and it is why the whole auth and sync layer is testable with no Obsidian in the room at all.

Two things requestUrl does that will cost you an afternoon

It throws on any status ≥ 400 unless you pass throw: false — and a 403 body is precisely what you need when you are diagnosing a bot challenge.

It keeps no cookie jar, and its headers is a flat Record<string, string>, so the seven Set-Cookie values Garmin returns arrive glued into one comma-joined string. Cookie expiry dates contain commas too, so a naive split mangles them; we split only on a comma followed by a name=.

The build enforces the rest: npm run build fails if a node or electron require leaks into the bundle.

Raw numbers aren't worth much

Pulling data in is the easy half. A folder of JSON tells you nothing.

Two things fix that. First, metrics land as note properties rather than prose, so Dataview and Bases can query them — and the plugin generates a Bases table view beside the notes on the first sync that writes anything.

yaml
# Garmin/2026-09-12.md
---
date: 2026-09-12
steps: 8432
resting_hr: 48
sleep_hours: 7.5
body_battery_high: 88
hrv_avg: 42
training_readiness: 71
---

Second, a dashboard.

The plugin dashboard: a row of stat tiles with week-over-week deltas and sparklines, a steps column chart against a 10,000 goal line, and sleep hours broken into deep, light, REM and awake stages
The dashboard, rendered in the browser preview harness — the same Svelte components the plugin mounts inside Obsidian.

A few decisions here are easy to get wrong:

  • Sleep stages take one hue in four steps, not four colours. Deep → light → REM → awake is an ordered scale; four categorical hues would encode order as identity.
  • No dual-axis charts anywhere. Two measures at different scales get two charts. Resting HR, HRV, Body Battery and readiness are small multiples with one axis each.
  • Deltas know which way is good. A falling resting heart rate is green and a rising one is red — the opposite of steps. They compare the last seven days against the seven before, because a single day of Garmin data swings too much to be a trend.
  • Colour never carries meaning alone. Every chart has a legend or an arrow, and a table view as its twin.

The charts are hand-drawn SVG. A plugin cannot load external scripts, and a bundled chart library would be dead weight on a phone. The components are Svelte 5 and import nothing from Obsidian, which is what lets the entire dashboard mount in a plain browser — the screenshot above is that harness, not a crop of the app.

What it doesn't do yet

Two-factor authentication. verifyMfa() is written and typechecked but has never run, because the test account is never challenged, so signing in raises a specific error rather than pretending to handle it. If your Garmin account has MFA switched on, wait for the next release.

One caution, since it is the sort of thing you only learn the hard way: Garmin limits login attempts per IP and can lock an account after repeated failures. Sign in deliberately. Your password is used for that one request and never written anywhere — but the refresh token that is stored is durable account access sitting in a vault you sync, so sign out from settings when you are done with a device.

Acknowledgements

  • Garmin Health Sync, for the idea and for the lesson about how much you can do with file properties and daily notes. We wanted headless authentication and one plugin for both platforms; the starting point is theirs.
  • cyberjunky/python-garminconnect, one of very few Garmin Connect libraries and the only actively maintained one we know of. It is Python, but the logic is what got us moving.
  • health-md, for showing us what health visualization widgets should feel like in a note.
  • The Obsidian community, for the support and the beta testing.