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.
Four metadata fields that carry behaviour
Section titled “Four metadata fields that carry behaviour”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_typeisOAUTH1, which is what makes the callback readoauth_token+oauth_verifierinstead ofcode+state.statusis only a default: the route replaces it per user with what is actually linked.connect_info_fieldsis 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.
Linking: the two stages of OAuth 1.0a
Section titled “Linking: the two stages of OAuth 1.0a”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.
- Stage one: request a token fetch a request token from Garmin, with the request signed per OAuth 1.0a
- Token secret and user id parked written to Redis with a TTL from
OAUTH_TEMP_TTL_SECONDS(default 900 seconds) - The user consents in a browser the authorization URL is returned to the client as
link_web_url - Garmin redirects to the callback
GET /api/v1/pulse/providers/theta_garmin/callback?oauth_token=…&oauth_verifier=… - Stage two: trade for permanent tokens exchange for an access token and token secret; the temporary state is read once and deleted
- Credentials stored, backfill kicked off store the credentials, then pull the last 7 days asynchronously
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.
Webhook push instead of a pull task
Section titled “Webhook push instead of a pull task”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.
Storing raw payloads, and deduplication
Section titled “Storing raw payloads, and deduplication”Each webhook item carries Garmin’s own user id, which means nothing to your database. So identity is resolved before anything is written:
userIdon 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_idfalls back to a timestamp when no item has one
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.
Mapping to StandardPulseData
Section titled “Mapping to StandardPulseData”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.
Derived indicators
Section titled “Derived indicators”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.
Unlink and vendor notification
Section titled “Unlink and vendor notification”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.
What to take from it
Section titled “What to take from it”- 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
typefield 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.
Next steps
Section titled “Next steps”The contracts this provider implements
Start your own from this shape
theta_garmin has fixtures already — use them
The routes that drive all of the above
The source is under mirobody/pulse/providers/, in mirobody_garmin_connect/.