config.devices.yaml (named by the
INCLUDE list at the top of config.yaml), not in config.yaml itself: a
deployment that never connects a wearable never sees them. That file ships with
empty credentials and each vendor’s endpoint defaults already filled in; put your
credentials there, or in your config.{env}.yaml overlay, which overrides it
(the YAML blocks below work in either).
Apple Health is not in this list, and cannot be. HealthKit is readable only from a signed iOS app, on-device, after per-type user consent — there is no web OAuth flow and no server-to-server API. This server receives Apple data (/apple/health,/apple/statistics,/apple/cda) from a client that already has it. Seecollect/providers/apple/.
Before you start
A publicly reachable HTTPS URL. Every vendor redirects the user’s browser back to your server after they approve, and none of them acceptlocalhost or
plain HTTP for a registered redirect. For local development, tunnel:
MCP_PUBLIC_URL in
config.{env}.yaml so the rest of the server agrees about its own address.
The callback route already exists. It is served at:
platform is always theta for these three. The slugs are theta_garmin,
theta_oura, theta_whoop — the full slug, not the bare vendor name; the
handler looks the provider up by exactly that string
(ProviderPlatform.get_provider).
So the redirect URL you register with the vendor, and the one you put in
config, are the same string, and it looks like:
Oura
OAuth 2.0. Register at cloud.ouraring.com/oauth/applications. Set the redirect URI to the callback above withtheta_oura.
Whoop
OAuth 2.0. Register at developer.whoop.com and set the redirect URI withtheta_whoop.
WHOOP_AUTH_URL, WHOOP_TOKEN_URL, WHOOP_API_BASE_URL, WHOOP_SCOPES,
WHOOP_REQUEST_TIMEOUT, WHOOP_CONCURRENT_REQUESTS, WHOOP_MAX_DETAIL_RECORDS.
Garmin
OAuth 1.0a, not 2.0 — the flow is request-token → user authorises →oauth_verifier → access-token, and the callback handler branches on it
(LinkType.OAUTH1). Apply through the
Garmin Connect Developer Program;
approval is a manual process and is usually the long pole.
GARMIN_AUTH_URL, GARMIN_TOKEN_URL,
GARMIN_ACCESS_TOKEN_URL, GARMIN_API_BASE_URL, OAUTH_TEMP_TTL_SECONDS.
Secrets are encrypted at rest automatically: any key whose name contains_SECRET,_KEY,_TOKEN,_PASSWORD… is encrypted withCONFIG_ENCRYPTION_KEYfrom.envthe first time the server reads it. Paste the plaintext once; the file rewrites itself.
Verify
1. The provider starts. Restart and read the boot log:Failed to load provider … at WARNING is a different problem:
that is an import error, and a regression test covers it.
2. It is offered to users.
POST /api/v1/pulse/user/providers/link (authenticated)
returns the vendor authorisation URL; open it, approve, and the vendor sends the
browser to your callback. On success the user’s linked providers appear in:
POST /api/v1/pulse/user/providers/unlink.
4. Data arrives either on the pull schedule the provider registers at
startup, or via webhook: POST /api/v1/pulse/{platform}/{provider}/webhook,
which is the endpoint you give the vendor for push notifications. Webhooks are
off (404) until you set COLLECT_WEBHOOK_SECRET; then register the URL with
?secret=<value>, or send the value in X-Webhook-Secret. A push names its
person by the vendor’s own user id, which is matched through the linked account.
Troubleshooting
loaded 0 providers and no other line. Every provider declined. Check the
key names against this page — a typo in OURA_CLIENT_ID looks identical to not
configuring it, because create_provider returns None either way.
Vendor rejects the redirect URI. It must match what you registered
byte for byte, including scheme, host, path and the absence of a trailing
slash. The most common miss is the slug: theta_oura, not oura.
Callback returns “provider not available”. The provider declined at startup,
so the platform has nothing registered under that slug. Fix the credentials
first; the callback is downstream of registration.
It worked, then stopped after an hour. Access tokens expire and are
refreshed through refresh_access_token; if a refresh token was never stored,
the vendor consent needs the offline/refresh scope. Re-link once with the right
scope configured.
Writing your own
The provider contract is one directory:mirobody_<slug>/provider_<slug>.py, exporting a BasePullProvider subclass
with create_provider(config) returning None when unconfigured.
mirobody_whoop/ is the OAuth2
reference; mirobody_oura/ is the
same shape with a different vendor. Full guide: provider-guide.md.
Providers outside the package go in PROVIDER_DIRS; those are loaded by file
location, so use absolute imports in them.