Skip to content
Get Started

Building on Mirobody

Garmin Provider Example

The decisions behind the shipped Garmin provider: the two-stage OAuth 1.0a link, why it registers no pull task, how webhook payloads deduplicate, and mapping fields as data rather than branches.

The Garmin integration is the most complete of the four shipped providers, and worth reading before writing your own: it is the only OAUTH1 source, the only one that deliberately turns scheduled pulling off, and the only one that calls the vendor back on unlink.

info is re-evaluated on every call and touches nothing external — listing providers must cost neither a round trip nor credentials. Four of its fields carry behaviour:

  • slug (theta_garmin) is the routing key: it appears in the webhook path, the callback path, the credential row, and it is the pull task’s identity.
  • auth_type is OAUTH1, which is what makes the callback read oauth_token + oauth_verifier instead of code + state.
  • status is only a default: the route replaces it per user with what is actually linked.
  • connect_info_fields is absent, and that absence is how a client knows to open a browser rather than draw a form.

In configuration, the four endpoint URLs and the TTL ship filled in; what is blank is GARMIN_CLIENT_ID, GARMIN_CLIENT_SECRET and GARMIN_REDIRECT_URL. The factory gates the whole provider on the first two: without both it returns None, so a misconfigured deployment has no Garmin provider rather than a broken one.

OAuth 1.0a has to carry a secret between two HTTP round trips that share no session, so the handshake state is parked in Redis.

  1. Stage one: request a token fetch a request token from Garmin, with the request signed per OAuth 1.0a
  2. Token secret and user id parked written to Redis with a TTL from OAUTH_TEMP_TTL_SECONDS (default 900 seconds)
  3. The user consents in a browser the authorization URL is returned to the client as link_web_url
  4. Garmin redirects to the callback GET /api/v1/pulse/providers/theta_garmin/callback?oauth_token=…&oauth_verifier=…
  5. Stage two: trade for permanent tokens exchange for an access token and token secret; the temporary state is read once and deleted
  6. Credentials stored, backfill kicked off store the credentials, then pull the last 7 days asynchronously
One link request, one vendor redirect, and the temporary state that ties them together.

Stage two is where the security decision lives. The callback route is unauthenticated: the caller is Garmin’s redirect, not your user. So nothing in the query string is trusted beyond the two OAuth parameters, the user identity is recovered from Redis, and the temporary state is deleted on read, which makes the handover single-use.

One more thing can only be done at this moment: during linking, ask Garmin once for this user’s id on their side and store it on the row. Every later webhook depends on it having been captured here.

By default every provider gets a scheduled pull. Garmin turns it off explicitly, so nothing ever polls Garmin on a timer — the data arrives by vendor webhook. A pull happens exactly once: the backfill right after linking.

Vendor APIs with a bounded query window need chunking: Garmin caps a single query at 24 hours, so a multi-day backfill is split into one call per day and stitched back together.

Each webhook item carries Garmin’s own user id, which means nothing to your database. So identity is resolved before anything is written:

userId on a webhook item
the vendor-side id stored on the credential row one batched lookup, not one per item
that row's user
the Mirobody-side identity used from here on
any item's summaryId
msg_id falls back to a timestamp when no item has one
Identity is resolved before the write; a user that cannot be mapped is dropped with a warning rather than guessed.

A payload carrying a deregistration takes another exit: it is stored as usual, the matching user’s credential row is deleted — this is how Garmin tells you a user revoked consent on their side — and the item is then skipped rather than handed on to formatting.

Garmin’s mapping is data, not code: one table keyed by data type describes where each payload’s timestamp comes from, which flat fields become which indicator in which unit, which nested arrays are time series, and whether a derived handler runs afterwards. A very short generic interpreter executes the table, because the knowledge is in the table.

Three keys decide everything about a flat field: indicator, converter and unit. Indicator names are never string literals but references into the registry, so renaming something in the registry fails loudly instead of leaving a quietly wrong type in the database. converter is where units are aligned at the source: Garmin reports active duration in seconds and the written record is in minutes.

Missing fields are skipped silently; a field that is present but unconvertible counts as a skip and is logged. Nothing aborts the batch: failures are collected per data type and returned alongside the result, which is what GET /api/v1/manage/pulse/providers/check_format uses to diagnose a half-successful mapping.

Time series come in two shapes, named by the config: an array of samples each carrying a value and a time offset (heart-rate sampling), or one object that is offset → value (HRV, respiration). Either way a sample’s timestamp is the item’s base time plus the offset, so one daily-summary payload expands into hundreds of individually timestamped records.

Some indicators simply do not exist in Garmin’s payloads and have to be computed — that is what the derived handlers are for: the sleep one derives three indicators from two raw fields, and the dailies one adds active and basal energy together.

The interesting part is what is deliberately not mapped. Garmin has a duration field with the same shape as a standard indicator but not the same meaning; mapping it directly would produce a plausible wrong number, so it is left out of the table. Two derived sleep indicators are also not computed here: they need heart-rate samples intersected with the sleep window, and because Garmin sends one webhook per data type, no single call holds both — so they are deferred to the aggregator rather than approximated from incomplete data.

The base class’s unlink soft-deletes the credential row and stops. Garmin also requires a deregistration call, and its error semantics deserve attention: the local row is deleted on every path, including when the vendor call fails, but a vendor failure still raises. So an unlink error means “disconnected locally, Garmin may still be pushing”, not “nothing happened”. A credential row that was already absent is treated as already unlinked and reports success.

  • Write the mapping as data, not branches. A table keyed by data type plus a short interpreter means “support another data type” is a table entry, not a new method.
  • Reference the indicator registry, never a string. That is what keeps the type field honest.
  • Choose between push and pull explicitly. Scheduling off plus a one-time backfill after linking is the whole pattern for a push-based source.
  • Recover identity from your own storage, not from the redirect. The callback is unauthenticated; treating the parked state as the only trustworthy source of the user’s identity is what makes it safe.
  • Refuse to map a field whose meaning doesn’t match. Derive the indicator instead of emitting a plausible wrong value.

The source is under mirobody/pulse/providers/, in mirobody_garmin_connect/.