① Collect
Using Providers
The real /api/v1/pulse routes: fill in a provider's OAuth keys, connect an account by browser or by form, see what a user has linked, and let webhooks or the pull scheduler bring the data in.
The HTTP routes
Section titled “The HTTP routes”Everything a user or a vendor calls lives under /api/v1/pulse. Operator endpoints live under /api/v1/manage and are authenticated by an sk query parameter instead of a JWT.
| Route | Auth | Purpose |
|---|---|---|
GET /api/v1/pulse/providers | JWT optional | Every registered provider; per-user status merged in when a token is present |
GET /api/v1/pulse/user/providers | JWT | Only what this user has linked |
POST /api/v1/pulse/user/providers/link | JWT | Start or complete a connection |
GET /api/v1/pulse/{platform}/{provider}/callback | none | Where the vendor sends the browser back |
POST /api/v1/pulse/user/providers/unlink | JWT | Drop a connection |
POST /api/v1/pulse/user/providers/update-llm-access | JWT | Toggle whether the agent may read this source |
POST /api/v1/pulse/{platform}/{provider}/webhook | none | Vendor pushes, provider named in the path |
POST /api/v1/pulse/{platform}/webhook | none | Vendor pushes, provider inferred from the body |
GET /api/v1/pulse/providers/indicators | none | The indicator names and units a device maker may send |
POST /api/v1/pulse/{platform}/token | none | Exchange a device maker’s own user id + certification for a Mirobody token |
Every /api/v1/pulse response uses the same envelope — {"code": 0, "msg": "ok", "data": {…}} on success, {"code": …, "msg": "…"} on failure. Note that failures come back with HTTP 200 and a non-zero code; check the body, not the status line. The management routes share the success shape but report errors as {"code": …, "detail": "…"}.
Configure the provider’s credentials
Section titled “Configure the provider’s credentials”Providers read their keys through safe_read_cfg, so the usual three-layer resolution applies: environment variable, then config.{env}.yaml, then config.yaml. See Configuration for how that layering and the automatic encryption of _SECRET / _KEY-suffixed values work.
Garmin and Whoop already have their endpoints filled in by the shipped template; only the three client-specific values are blank:
# Garmin Platform Configuration.GARMIN_TOKEN_URL: https://connectapi.garmin.com/oauth-service/oauth/request_tokenGARMIN_AUTH_URL: https://connect.garmin.com/oauthConfirm/GARMIN_ACCESS_TOKEN_URL: https://connectapi.garmin.com/oauth-service/oauth/access_tokenGARMIN_API_BASE_URL: https://apis.garmin.com/wellness-api/restOAUTH_TEMP_TTL_SECONDS: 900GARMIN_CLIENT_ID: ""GARMIN_CLIENT_SECRET: ""GARMIN_REDIRECT_URL: ""
# Whoop Platform Configuration.WHOOP_SCOPES: offline read:recovery read:sleep read:cycles read:profile read:workout read:body_measurementWHOOP_TOKEN_URL: https://api.prod.whoop.com/oauth/oauth2/tokenWHOOP_AUTH_URL: https://api.prod.whoop.com/oauth/oauth2/authWHOOP_API_BASE_URL: https://api.prod.whoop.com/developer/v2WHOOP_CLIENT_ID: ""WHOOP_CLIENT_SECRET: ""WHOOP_REDIRECT_URL: ""Put your own values in config.{env}.yaml rather than editing the template. Oura has no block in the template at all, and the PostgreSQL provider needs its feature flag, so those go in the same file:
GARMIN_CLIENT_ID: your_garmin_consumer_keyGARMIN_CLIENT_SECRET: your_garmin_consumer_secretGARMIN_REDIRECT_URL: https://your-host/api/v1/pulse/providers/theta_garmin/callback
WHOOP_CLIENT_ID: your_whoop_client_idWHOOP_CLIENT_SECRET: your_whoop_client_secretWHOOP_REDIRECT_URL: https://your-host/api/v1/pulse/providers/theta_whoop/callback
OURA_CLIENT_ID: your_oura_client_idOURA_CLIENT_SECRET: your_oura_client_secretOURA_REDIRECT_URL: https://your-host/api/v1/pulse/providers/theta_oura/callback
ENABLE_PGSQL_DEVICE: "1"BACKEND_SERVER_SK: a_long_random_stringThree things about that block are worth spelling out:
- The redirect URL must be the callback route for that exact provider. Nothing derives it for you: Garmin hands
GARMIN_REDIRECT_URLstraight to Garmin as theoauth_callback, and the source comments that the callback is served by/api/v1/pulse/{platform}/{provider}/callback. Register the same URL on the vendor’s developer portal. OAUTH_TEMP_TTL_SECONDS(default 900) bounds the handoff. Between “we generated the authorization URL” and “the browser came back”, the token secret and the user id live in Redis under that TTL. A user who leaves the vendor’s consent screen open longer than 15 minutes has to start again.BACKEND_SERVER_SKis what theskquery parameter is compared against, and it is not in the shipped template. Without it every/api/v1/manage/*route answers500 Server configuration error: management key not configured.
List the connectable sources
Section titled “List the connectable sources”GET /api/v1/pulse/providers walks the registry, so it reflects exactly what loaded at startup. The JWT is optional: without one you get static metadata, with one the router overwrites status and fills in the per-user counters.
curl "http://localhost:18080/api/v1/pulse/providers" \ -H "Authorization: Bearer $JWT"Connected providers sort first, then unconnected, then unsupported; within the unconnected group a fixed priority list puts Whoop and Garmin near the top. Each entry looks like this:
{ "slug": "theta_garmin", "name": "Garmin Connect", "description": "Garmin fitness and health data integration via OAuth", "logo": "https://static.thetahealth.ai/res/garmin.png", "supported": true, "auth_type": "oauth1", "status": "available", "platform": "theta", "connected_at": null, "last_sync_at": null, "record_count": 0, "allow_llm_access": false, "connect_info_fields": null}Two optional query parameters narrow it: platform (only that platform’s providers) and status (connected / unconnected / unsupported). A nocache flag is forwarded to each platform’s get_providers, and owner_user_id returns another user’s providers — refused unless a sharing permission check passes.
connect_info_fields is the field that tells a client which flow to render: null means send the user to a browser, a list means draw that form. Only theta_pgsql returns a list.
Connect an account
Section titled “Connect an account”One route starts every connection — POST /api/v1/pulse/user/providers/link — but it behaves in two quite different ways depending on the provider. Its auth_type field is an enum with four accepted values: password, oauth2, token, customized.
Garmin, Whoop and Oura all return a URL and finish on the callback. Send oauth2 as the auth_type — the three OAuth providers override link() themselves and build the authorization URL without inspecting that field, so there is no oauth1 value to send for Garmin:
curl -X POST "http://localhost:18080/api/v1/pulse/user/providers/link" \ -H "Authorization: Bearer $JWT" \ -H "Content-Type: application/json" \ -d '{ "provider_slug": "theta_garmin", "platform": "theta", "auth_type": "oauth2", "return_url": "https://your-app/settings/devices" }'The response carries data.link_web_url. Open it, let the user consent, and the vendor redirects to your configured callback.
A CUSTOMIZED provider is validated and stored in this one request — no browser, no callback. Send the connect_info object matching the connect_info_fields the provider advertised:
curl -X POST "http://localhost:18080/api/v1/pulse/user/providers/link" \ -H "Authorization: Bearer $JWT" \ -H "Content-Type: application/json" \ -d '{ "provider_slug": "theta_pgsql", "platform": "theta", "auth_type": "customized", "connect_info": { "username": "readonly", "password": "secret", "host": "pg", "port": "5432", "database": "analytics" } }'The provider’s _validate_credentials_v2 runs first, so a wrong password or an unreachable host fails the request rather than storing a broken connection. A PASSWORD provider would use auth_type: "password" with top-level username / password instead — no shipped provider uses that path.
You never have to pass the right platform: any provider_slug beginning with theta_ is rerouted to the theta platform before dispatch.
Ask for the authorization URL
POST /api/v1/pulse/user/providers/link. The provider stores whatever it needs for the round trip in Redis, keyed by the OAuth token (OAuth 1.0a) or by the state value (OAuth 2.0), and returns link_web_url.
Send the user there
Open link_web_url in a browser or popup. The user authenticates with the vendor, not with you.
The vendor calls the callback
GET /api/v1/pulse/{platform}/{provider}/callback. It is unauthenticated by design: the caller is the vendor’s redirect, not the user. Identity is recovered from Redis rather than the query string, so a forged user_id cannot be used.
The callback answers in one of two ways
If a return_url survived the round trip, it 302s there with code, success, platform, provider and provider_slug appended. Otherwise it returns a small HTML page that postMessages a <PROVIDER>_OAUTH_COMPLETE message (for example GARMIN_OAUTH_COMPLETE) to window.opener and closes itself — which is what makes the popup pattern work without a redirect target.
Where credentials are stored
Section titled “Where credentials are stored”Both flows end in the same place: one row per user and provider in health_user_provider. Which columns get filled depends on the link type — username + password for PASSWORD, access_token + access_token_secret for OAUTH1, access_token + refresh_token + expires_at for OAUTH2, a connect_info JSON object for CUSTOMIZED. Secrets are encrypted on the way in and decrypted only when a pull needs them.
Relinking is atomic: a single data-modifying CTE soft-deletes the previous active row and inserts the new one in the same statement, so a failure cannot leave a user with the old credentials deleted and no new ones. The write also forces reconnect = 0, clearing any earlier “needs reconnect” flag.
That flag is how a broken connection surfaces. get_all_user_credentials_for_provider only returns rows with reconnect = 0, so a user marked for reconnection is skipped by the scheduler instead of being retried forever, and GET /api/v1/pulse/user/providers reports their status as reconnect rather than connected.
Inspect and gate what is connected
Section titled “Inspect and gate what is connected”GET /api/v1/pulse/user/providers returns just this user’s connections — slug, status, and the connection timestamps. It is the cheaper call when you only need to know whether something is linked; unlike GET /api/v1/pulse/providers it does not run the statistics pass, so record_count stays 0 and last_sync_at stays null there.
Whether the agent may read a source is a separate, per-connection switch:
curl -X POST "http://localhost:18080/api/v1/pulse/user/providers/update-llm-access" \ -H "Authorization: Bearer $JWT" \ -H "Content-Type: application/json" \ -d '{"provider_slug": "theta_garmin", "platform": "theta", "llm_access": false}'Linking sets llm_access to 1, so a newly connected source is readable by default; this route is how a user turns that off. It surfaces in the provider list as allow_llm_access.
Data delivery
Section titled “Data delivery”There are exactly two ways in, and which one a provider uses is its own decision — see the cadence table in Pulse Provider System.
Webhooks. A push-based vendor posts to POST /api/v1/pulse/{platform}/{provider}/webhook; for Garmin that is /api/v1/pulse/providers/theta_garmin/webhook. Naming the provider in the path is the reliable form. The shorter POST /api/v1/pulse/{platform}/webhook infers the provider from the body instead — a top-level source string, or data.source.slug. Both routes are unauthenticated, and both read a Svix-Id request header as the idempotency key, falling back to a timestamp string when it is absent.
Scheduled pulls. A provider that asks for a scheduled task gets one, and it loads every linked user’s credentials and fetches from the vendor per user. Whoop and Oura work this way; Garmin does not.
Either way the payload lands in the same place: the provider’s save_raw_data_to_db, then format_data_v2, then the record writer. The type field of each produced record must be a registered indicator name — see Health Indicators — and Data Flow covers what happens after the write.
Disconnect
Section titled “Disconnect”curl -X POST "http://localhost:18080/api/v1/pulse/user/providers/unlink" \ -H "Authorization: Bearer $JWT" \ -H "Content-Type: application/json" \ -d '{"provider_slug": "theta_garmin", "platform": "theta"}'The base implementation soft-deletes the row. A provider may override unlink to tell the vendor as well, which Garmin does — and it treats a failed vendor call as an error even though it always removes the local row, so a 500 here can still mean “locally disconnected, vendor not informed”.
Already-stored records are not deleted by unlinking. Unlinking stops new data; it is not a data-erasure call.
Operator endpoints
Section titled “Operator endpoints”The management routes take ?sk=<BACKEND_SERVER_SK> and no JWT. The ones that matter while wiring a provider up:
MANAGE="http://localhost:18080/api/v1/manage"
# what loaded, and what the scheduler thinks it is doingcurl "$MANAGE/pulse/providers/providers?sk=$SK"curl "$MANAGE/theta/pull/status?sk=$SK"curl "$MANAGE/theta/pull/config?sk=$SK"
# stop waiting for the timercurl -X POST "$MANAGE/theta/pull/trigger?sk=$SK" \ -H "Content-Type: application/json" \ -d '{"provider_slug": "theta_oura", "force": true}'
# what arrived, and what one payload formats intocurl "$MANAGE/pulse/providers/webhooks?sk=$SK&provider=theta_garmin&page=1&page_size=20"curl "$MANAGE/pulse/providers/check_format?sk=$SK&provider=theta_garmin&id=123"
# what a user ended up withcurl "$MANAGE/pulse/user-data-sources?sk=$SK&user_id=505"curl "$MANAGE/pulse/user-indicators?sk=$SK&user_id=505"curl "$MANAGE/pulse/user-health-data?sk=$SK&user_id=505&start_date=2026-08-01&end_date=2026-08-05"check_format is the one to reach for first when records are missing: it reloads a stored raw payload by id, runs the provider’s format_data_v2 over it, and returns the original next to the normalised result — so you can see whether the problem is ingestion or mapping without redoing the vendor round trip. POST /api/v1/manage/theta/pull/start and /stop control the scheduler itself, and user-health-data refuses ranges longer than seven days.
Next steps
Section titled “Next steps”Which sources exist and what each one needs
A full implementation of the flows above
Write a source of your own
Check your mapping against fixtures