Skip to content
Get Started

① 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.

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.

RouteAuthPurpose
GET /api/v1/pulse/providersJWT optionalEvery registered provider; per-user status merged in when a token is present
GET /api/v1/pulse/user/providersJWTOnly what this user has linked
POST /api/v1/pulse/user/providers/linkJWTStart or complete a connection
GET /api/v1/pulse/{platform}/{provider}/callbacknoneWhere the vendor sends the browser back
POST /api/v1/pulse/user/providers/unlinkJWTDrop a connection
POST /api/v1/pulse/user/providers/update-llm-accessJWTToggle whether the agent may read this source
POST /api/v1/pulse/{platform}/{provider}/webhooknoneVendor pushes, provider named in the path
POST /api/v1/pulse/{platform}/webhooknoneVendor pushes, provider inferred from the body
GET /api/v1/pulse/providers/indicatorsnoneThe indicator names and units a device maker may send
POST /api/v1/pulse/{platform}/tokennoneExchange 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": "…"}.

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:

config.yaml
# Garmin Platform Configuration.
GARMIN_TOKEN_URL: https://connectapi.garmin.com/oauth-service/oauth/request_token
GARMIN_AUTH_URL: https://connect.garmin.com/oauthConfirm/
GARMIN_ACCESS_TOKEN_URL: https://connectapi.garmin.com/oauth-service/oauth/access_token
GARMIN_API_BASE_URL: https://apis.garmin.com/wellness-api/rest
OAUTH_TEMP_TTL_SECONDS: 900
GARMIN_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_measurement
WHOOP_TOKEN_URL: https://api.prod.whoop.com/oauth/oauth2/token
WHOOP_AUTH_URL: https://api.prod.whoop.com/oauth/oauth2/auth
WHOOP_API_BASE_URL: https://api.prod.whoop.com/developer/v2
WHOOP_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:

config.localdb.yaml
GARMIN_CLIENT_ID: your_garmin_consumer_key
GARMIN_CLIENT_SECRET: your_garmin_consumer_secret
GARMIN_REDIRECT_URL: https://your-host/api/v1/pulse/providers/theta_garmin/callback
WHOOP_CLIENT_ID: your_whoop_client_id
WHOOP_CLIENT_SECRET: your_whoop_client_secret
WHOOP_REDIRECT_URL: https://your-host/api/v1/pulse/providers/theta_whoop/callback
OURA_CLIENT_ID: your_oura_client_id
OURA_CLIENT_SECRET: your_oura_client_secret
OURA_REDIRECT_URL: https://your-host/api/v1/pulse/providers/theta_oura/callback
ENABLE_PGSQL_DEVICE: "1"
BACKEND_SERVER_SK: a_long_random_string

Three 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_URL straight to Garmin as the oauth_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_SK is what the sk query parameter is compared against, and it is not in the shipped template. Without it every /api/v1/manage/* route answers 500 Server configuration error: management key not configured.

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.

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

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.

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:

Terminal window
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.

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.

Terminal window
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.

The management routes take ?sk=<BACKEND_SERVER_SK> and no JWT. The ones that matter while wiring a provider up:

Terminal window
MANAGE="http://localhost:18080/api/v1/manage"
# what loaded, and what the scheduler thinks it is doing
curl "$MANAGE/pulse/providers/providers?sk=$SK"
curl "$MANAGE/theta/pull/status?sk=$SK"
curl "$MANAGE/theta/pull/config?sk=$SK"
# stop waiting for the timer
curl -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 into
curl "$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 with
curl "$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.