Google Health Integration — Next Steps
This document is the operator handover for the implementation that landed in this branch. Everything in code is built; what is left is configuration, external setup, and verification you can only do as the owner of the Google Cloud project and a real Google account with Google Health data.
If you want a refresher on what was built and why, read
docs/google-health/project-plan.md (especially the "Corrections Applied
During Implementation Review" section at the top).
TL;DR
- Create a Google Cloud project and enable the Google Health API at
console.developers.google.com/apis/library/health.googleapis.com. - Configure the OAuth consent screen (Privacy Policy + Terms of Service
URLs are required for the
googlehealth.*sensitive scopes). - Create a Web application OAuth client with the redirect URI:
https://your-domain/health_connections/google_callback. - Add credentials to Rails:
bin/rails credentials:editgoogle_health: client_id: "xxxxx.apps.googleusercontent.com" client_secret: "GOCSPX-xxxxx" redirect_uri: "http://localhost:3004/health_connections/google_callback" project_id: "your-gcp-project-id" # only required for webhooks webhook_secret: "a-long-random-string" # only required for webhooks webhook_endpoint_url: "https://your-public-tunnel/webhooks/google_health" # optional override - Enable Active Record encryption (see "Token Encryption" below).
- Visit
/health, click Connect Google Health, complete OAuth. - Open Mission Control Jobs (
/jobs) and watchHealth::BackfillJob+Health::SyncJobrun. - Visit
/health_sport_mappingsand/health_habit_rulesto configure how imported exercise sessions become activity logs and which habits auto-complete from synced data.
1. Google Cloud Project Setup
Follow the official Google Health API setup guide: https://developers.google.com/health/setup
- Open https://console.cloud.google.com and create a project, e.g.
Lifehub Health. - APIs & Services → Library → enable Google Health API (direct link:
console.developers.google.com/apis/library/health.googleapis.com). - APIs & Services → OAuth consent screen / Branding:
- User type: External (or Internal for a Google Workspace).
- App name:
Lifehub. - User support email and developer email: yours.
- Authorized domains: your production hostname (e.g.
lifehub.app). - Privacy Policy URL and Terms of Service URL — required by
Google before they will release the
googlehealth.*scopes to real users.
- Audience (
console.developers.google.com/auth/audience) — add your Google account as a test user while the app is in testing mode (Google caps testing-mode clients at 100 test users). - Data Access / Scopes (
console.developers.google.com/auth/scopes): search for "Google Health API" and add the readonly scopes Lifehub requests (see §3.2 below). - Credentials → Create credentials → OAuth client ID → Web application:
- Name:
Lifehub Web. - Authorized redirect URIs:
http://localhost:3004/health_connections/google_callbackhttp://127.0.0.1:3000/health_connections/google_callbackhttps://your-production-domain/health_connections/google_callback
- Save the Client ID and Client Secret.
- Name:
⚠️ Google's setup tutorial uses
https://www.google.comas the redirect URI because it's walking developers through the manual OAuth Playground flow. We do not use that — Lifehub uses the standard Web Application flow and our own callback URL.
2. Rails Configuration
2.1 Credentials
bin/rails credentials:edit
Add:
google_health:
client_id: "xxxxx.apps.googleusercontent.com"
client_secret: "GOCSPX-xxxxx"
redirect_uri: "http://localhost:3004/health_connections/google_callback"
# The next three are only needed once you enable webhooks (§4):
project_id: "your-gcp-project-id"
webhook_secret: "a-long-random-string"
webhook_endpoint_url: "https://applifehub.com/webhooks/google_health"
Where each value comes from:
client_id/client_secret— from the OAuth client you created in §1 step 6. They appear once on the Credentials page after you click "Create"; you can also re-download them later from https://console.cloud.google.com/apis/credentials.redirect_uri— must match a URI you registered in §1 step 6, byte for byte. If you omit it, the app falls back toConfiguration.default_host + /health_connections/google_callback(http://localhost:3004in development,https://applifehub.comin production). Set it explicitly when running on a non-default port.project_id— the Project ID of your Google Cloud project (a slug likelifehub-health-431207, NOT the display name). Find it in the Cloud Console project picker (shown asID:beneath the name) or at https://console.cloud.google.com/iam-admin/settings. Setting this also adds thecloud-platformscope to the OAuth consent request, so all existing users must reconnect (see §4.1).webhook_secret— you generate this yourself. Run:bin/rails runner 'puts SecureRandom.hex(32)'Paste the output into credentials. Lifehub passes it to Google as
endpointAuthorization.secret = "Bearer <your-secret>", and Google forwards that same string in theAuthorizationheader on every push.webhook_endpoint_url— the publicly reachable HTTPS URL where Google posts notifications. Production:https://applifehub.com/webhooks/google_health. Local dev: a tunnel URL likehttps://abc-123.trycloudflare.com/webhooks/google_healthorhttps://abc-123.ngrok.io/webhooks/google_health. The localhost fallback won't work — Google can't reach your laptop without a tunnel.
2.2 OAuth scopes used by Lifehub
These are baked into Health::Google::Configuration::SCOPES and must match
the scopes you add to the OAuth consent screen:
https://www.googleapis.com/auth/googlehealth.activity_and_fitness.readonly
https://www.googleapis.com/auth/googlehealth.health_metrics_and_measurements.readonly
https://www.googleapis.com/auth/googlehealth.sleep.readonly
https://www.googleapis.com/auth/googlehealth.profile.readonly
openid
email
Lifehub does not request write or location scopes in v1.
2.3 Feature gate
The "Connect Google Health" button is hidden when credentials are missing. You can additionally gate it on:
config.x.google_health.enabled = true
(checked via Health::Google::Configuration.enabled?).
2.4 Token encryption (REQUIRED before production)
Active Record encryption was not configured in this app before this feature. Generate keys once:
bin/rails db:encryption:init
Copy the three keys into bin/rails credentials:edit:
active_record_encryption:
primary_key: "<from db:encryption:init>"
deterministic_key: "<from db:encryption:init>"
key_derivation_salt: "<from db:encryption:init>"
Then enable encryption on the token columns in
app/models/health/connection.rb. Lifehub ships with
support_unencrypted_data: true on both encrypted attributes so existing
plaintext rows remain readable during the migration window:
class Health::Connection < ApplicationRecord
encrypts :access_token, support_unencrypted_data: true
encrypts :refresh_token, support_unencrypted_data: true
end
Migration playbook
If you turn encryption on with live connections already in the database, follow this order:
- Deploy with
support_unencrypted_data: true(the default in this branch). Plaintext reads stop failing immediately. - Re-save all connections so existing tokens get encrypted. Either:
- Easiest — disconnect via the UI and reconnect once. The OAuth callback writes fresh tokens through the encrypted accessor.
- Or bulk-encrypt in a Rails console:
Health::Connection.find_each do |c| c.update!(access_token: c.access_token, refresh_token: c.refresh_token) endAssigning the same value marks the attribute dirty, which triggers encryption on save.
- Once every row has been touched, tighten security by changing both
support_unencrypted_data: truelines to drop the option:encrypts :access_token encrypts :refresh_tokenFrom that point, any unencrypted bytes in those columns will raise
ActiveRecord::Encryption::Errors::Decryptionagain.
Decryption-safety net
The codebase has defensive rescues so a misconfigured encryption (lost key, key rotation midway, partially-migrated row) never produces a 500:
HealthConnectionsController#destroyreads the refresh token viaconnection.safe_refresh_token(returnsnilon decryption failure) and silently skips the Google revoke step, so disconnect always succeeds locally.Health::SyncRunnercatches decryption errors during sync and flips the connection to:revokedwith a "Please reconnect" message. The dashboard then redirects to/health/connect.
3. Verify the OAuth Flow Locally
- Run
bin/dev— this starts the server on port 3004 and the Solid Queue worker as a Procfile process (you don't need a separatebin/jobs). - Visit
http://localhost:3004/health. - Click Connect Google Health. You'll be redirected to Google.
- Approve the scopes with your test-user Google account.
- Google redirects back to
/health_connections/google_callback?code=…. - The controller exchanges the code, stores tokens, optionally registers
the webhook subscriber (if
project_id+webhook_endpoint_urlare set), then kicks offHealth::BackfillJob.perform_later. - Watch
/jobsforHealth::BackfillJoband follow-upHealth::SyncJob. - After ~30 seconds you should see exercise sessions and daily metrics on
/health.
Common gotchas:
redirect_uri_mismatch— the URI in Rails credentials must match a URI registered in the Google Cloud OAuth client byte-for-byte (including the port).access_denied— your account is not in the Google Cloud test users list, or the consent screen was cancelled.invalid_granton first refresh — Google did not return a refresh token (only returned on first consent or whenprompt=consentis set). The code already sendsprompt=consent; if this triggers, disconnect and reconnect.- Testing-mode refresh tokens expire after 7 days. Production tokens last roughly six months. Promote the OAuth client out of testing mode before depending on long-lived connections.
4. Webhook Subscribers (optional but recommended)
Google Health API supports first-class subscribers — Google pushes data change notifications to your endpoint so you do not have to poll. The implementation is gated on credentials so you can ship without webhooks and turn them on later.
4.1 Prerequisites
Health::Google::Configuration.webhooks_configured? returns true only when
all four of these are set, and only then will the connect flow attempt
to register a subscriber:
project_id— so Lifehub knows the GCP project that owns the subscriber.webhook_secret— the value Google forwards verbatim in theAuthorizationheader (prefixed withBearerby Lifehub).webhook_endpoint_url— must start withhttps://. In development use a Cloudflare Tunnel or ngrok URL and set it explicitly; the localhost fallback won't be reachable from Google.client_id+client_secret(required for the connection itself).
⚠️ Additional OAuth scope. Subscriber creation requires the
https://www.googleapis.com/auth/cloud-platformscope. Lifehub adds this automatically whenproject_idis set — but every existing connection must reconnect after you addproject_id, because the access token they have won't include the new scope.
⚠️ Supported data types are limited. Google only allows webhook subscribers for:
altitude, distance, floors, sleep, steps, weight. Exercise and heart-rate are NOT subscribable today — those still rely on the polledHealth::SyncJob(every 2 hours).
4.2 Verification handshake
Before projects.subscribers.create succeeds, Google POSTs to your
webhook_endpoint_url twice:
- With
Authorization: Bearer <webhook_secret>and body{"type":"verification"}. Lifehub'sWebhooks::GoogleHealthControllerreturns HTTP 201 Created (Google checks for 201 specifically). - Without the
Authorizationheader. Lifehub returns HTTP 401 Unauthorized.
Both checks must pass or the subscriber isn't created. If you see a generic "could not create subscriber" failure, check that the public webhook URL is reachable from Google's IPs and that no proxy/CDN is rewriting the Authorization header.
4.3 Behavior
When webhooks_configured? is true:
- On successful OAuth connect, the controller calls
Health::Google::SubscriberManager.register(connection)which POSTs to/v4/{parent=projects/*}/subscriberswith the data type filters listed inHealth::Google::SubscriberManager::DEFAULT_DATA_TYPES. - Google stores the subscriber and returns a
namelikeprojects/{project}/subscribers/{subscriber}, which Lifehub persists inhealth_connections.webhook_subscriber_name. - On disconnect,
SubscriberManager.unregisterDELETEs that subscriber. - Incoming webhook calls (
POST /webhooks/google_health) are verified with the shared secret, then enqueueHealth::WebhookSyncJobfor the matching connection.
If webhooks_configured? is false, the system falls back to polling via
the health_sync_connected_users recurring job (every 2 hours).
5. Configure Imports
After the first sync, two screens become useful:
/health_sport_mappings— every distinct Google Health exercise type you imported has a row. Pick the Lifehub sport it should map to, decide whether unmapped types still create activity logs, and decide whether Lifehub should auto-create a sport./health_habit_rules— pick a habit, a rule type, and a threshold:exercise_session→ completes when any exercise of a given type happens that day (e.g.running).sport_activity→ completes when an imported activity logs against a specific Lifehub sport.daily_metric_threshold→ completes when a metric (steps, distance, calories, etc.) hits a number for the day.duration_threshold→ completes when total minutes of a specific exercise type for the day reach a number.
Habits are evaluated at the end of each sync run via
Health::HabitRuleEvaluator, which calls Habit#complete_on! so streaks
and goals refresh normally.
6. XP Policy for Imports
For v1, imports do not award XP. Reasoning:
- Backfilling historical data would mint dozens of XP transactions even for a 14-day window.
- The existing
ActivityLogsController#createandHabitsController#toggleXP paths are not idempotent against provider replays. - Google Health can correct/update sessions after the fact.
If you later want to award XP for imports, gate it on a unique source key
(e.g. google_health:<activity_log_id> and
google_health:<habit_id>:<date>:<rule_id>) so replays are no-ops.
7. Scheduled Sync
config/recurring.yml already has:
health_sync_connected_users:
class: Health::SyncConnectedUsersJob
schedule: every 2 hours
Restart the worker after deploys (bin/jobs). To trigger manually:
Health::SyncConnectedUsersJob.perform_later
If you turn webhooks on (§4), you can safely lengthen the schedule (or keep it as a safety net).
7.1 OAuth Verification — "In Production" ≠ "Verified Scopes"
This is the single most confusing part of Google's OAuth model, and worth understanding before you depend on the integration for real users.
The two-axis model
Google has two independent flags on every OAuth client:
- Publishing status —
TestingorIn production. Lifehub's OAuth client (the same one already used for Google login) is inIn production, so anyone with a Google account can complete the OAuth flow without being on a test-user list. - Per-scope verification — every sensitive or restricted scope is
individually
Approved,In review, orUnapproved. Adding newgooglehealth.*scopes does not carry over your existing login approval; those scopes startUnapproved.
"In production" only means the app is publicly discoverable. It does NOT mean Google has verified the scopes you request.
What you get before verification, with an "In production" client
When you add the googlehealth.* scopes to a production-published client
but haven't submitted them for verification yet:
| Behavior | |
|---|---|
| Who can connect | Anyone with a Google account ✅ |
| Test-user list required | No ✅ |
| Refresh token lifetime | ~6 months ✅ |
| Consent screen UX | Shows "Google hasn't verified this app" + user must click Advanced → Go to applifehub.com (unsafe) ⚠️ |
| OAuth user cap | 100 lifetime users who grant any unapproved scope — Google then blocks further grants permanently ⚠️ |
| Cap reset | Never — the 100 cap is for the project's lifetime ⚠️ |
The Audience page in the Cloud Console shows your current usage as N/100.
The counter increments every time a new user grants an unapproved scope.
How to confirm scope status
In the Cloud Console:
- Google Auth Platform → Verification Center — lists every scope
you request along with
Approved,In review, orNeeds verification. - Google Auth Platform → Data Access — the page where you add scopes to the consent screen. Scopes you add but don't submit will show as unapproved in the Verification Center.
Save the scopes from §2.2 on the Data Access page, then check the
Verification Center to see exactly which googlehealth.* scopes are
unapproved (likely all of them on first add).
Recommended workflow
- Build and ship today. With your client already in production, you
can connect from
applifehub.com, dogfood the integration, and let a handful of real users try it. Just expect the unverified warning and the burn-down clock on the 100-user cap. - Get Privacy Policy + ToS pages live on
applifehub.comthat specifically describe health-data handling. Verification cannot start without them. - Record a YouTube demo video showing the OAuth consent screen, what data Lifehub requests, and how each piece is displayed/used. Google requires this for sensitive/restricted scopes. Record it early — it's the most common reason verification stalls.
- Submit for verification via the Verification Center when stable.
Sensitive scopes are usually a 1–3 week turnaround. Restricted scopes
(which may include
googlehealth.health_metrics_and_measurements.readonly) add a CASA security assessment requirement — third-party audit, weeks of work, and meaningful cost. Confirm the classification in the Verification Center before committing to a timeline.
What changes after verification
| Behavior | |
|---|---|
| Consent screen UX | No warning — just the standard "Lifehub wants to access…" page ✅ |
| OAuth user cap | Lifted entirely for the approved scopes ✅ |
| Brand display | Your verified app name + logo show on the consent screen ✅ |
If verification is rejected, Google sends an email with the specific gap (usually missing/inadequate privacy policy, video, or scope justification). Address and resubmit — no penalty.
8. Production Rollout Checklist
Before opening this beyond yourself:
- Privacy Policy and Terms of Service URLs on the OAuth consent screen mention health-data usage.
- Google OAuth verification submitted (the
googlehealth.*scopes are sensitive and require review). - Production redirect URI registered in Google Cloud.
- Production
google_healthcredentials in encrypted credentials. active_record_encryptionkeys in encrypted credentials.encrypts :access_tokenandencrypts :refresh_tokenenabled onHealth::Connection.- Disconnect (
DELETE /health_connections/:id) tested — unregisters the subscriber, revokes viahttps://oauth2.googleapis.com/revoke, flips status todisconnected. bin/cipasses.- Brakeman + bundler-audit clean.
9. Manual Verification Plan
- Connect a Google account that has Google Health data (Fitbit, Pixel Watch, or a synced third-party integration).
- Sync — Mission Control shows
Health::BackfillJoband follow-upHealth::SyncJobrunning without error. - /health shows recent sessions, recent imported activity logs, and latest metrics.
- Sport mappings — map at least one provider exercise type to a Lifehub sport, run Sync now, confirm the activity log links to the right sport.
- Habit rules — create a rule (e.g. "running → complete 'Run today'"), import a running session, confirm the habit toggles on its date and streaks bump.
- Disconnect — confirm
last_synced_atstops moving, no new activity logs appear, and (if webhooks were on) the subscriber is removed in the Google Health API console. - Reconnect — confirm existing imported activity logs are not
duplicated. Idempotency comes from the unique
[user_id, source_provider, source_external_id]index.
10. Known Gaps and Follow-ups
Items I left out on purpose. Ranked by likelihood you'll want them:
- Active Record encryption is not enabled by default — must run
bin/rails db:encryption:initand addencryptstoHealth::Connectionbefore going live (see §2.4). - No XP for imports — see §6.
- No alternative providers — only Google Health. The schema is
provider-shaped (
Health::Connection#providerenum), so adding a second provider later is a model + importer change without touching the rest. - No granular intraday persistence —
Health::Google::DailySummaryImporterpulls a hard-coded list ofROLLUP_KEYS(steps, distance, etc. via:dailyRollUp) plus everydaily-*data type in the registry (daily-resting-heart-rate,daily-heart-rate-variability,daily-oxygen-saturation, etc.). Rawheart-rate,oxygen-saturation, and similar samples are not stored. To capture them, callHealth::Google::Client#list_data_points(data_type: "heart-rate", ...)directly and persist asHealth::DataPointwithrecord_kind: :sample. - No soft-delete reconciliation — Google reporting "this data point
was deleted" is not yet wired up. The
Health::*#deleted_atcolumns exist; add the call inHealth::Google::ExerciseImporter#call. - DataPoint value extraction is best-effort.
Health::Google::DailySummaryImporter#extract_valuewalks a small list of numeric field names. For data types that nest their value differently (e.g. heart-rate zone breakdowns), extend theNUMERIC_KEYSlist or add a per-type extractor. - No I18n strings — every label has a
default:fallback so the UI renders in English. Addhealth:keys toconfig/locales/en.ymlandpt-BR.ymlbefore shipping. - Pre-existing MCP test pollution — unrelated to this branch, but
bin/rails testshows ordering-dependent failures intest/integration/mcp/tools/. They pass in isolation.
11. Quick Reference: API Surface
| What | URL |
|---|---|
| API base | https://health.googleapis.com/v4 |
| Discovery doc | https://health.googleapis.com/$discovery/rest?version=v4 |
| OAuth authorization | https://accounts.google.com/o/oauth2/v2/auth |
| OAuth token exchange | https://oauth2.googleapis.com/token |
| OAuth revoke | https://oauth2.googleapis.com/revoke |
| User identity | GET /v4/users/me/identity |
| Data points list | GET /v4/users/me/dataTypes/{dataType}/dataPoints |
| Daily rollup | POST /v4/users/me/dataTypes/{dataType}/dataPoints:dailyRollUp |
| Physical-time rollup | POST /v4/users/me/dataTypes/{dataType}/dataPoints:rollUp |
| Create subscriber | POST /v4/projects/{project}/subscribers |
| Delete subscriber | DELETE /v4/projects/{project}/subscribers/{name} |
Supported data type slugs live in
app/models/health/google/data_type_registry.rb and include exercise,
sleep, steps, distance, floors, active-minutes,
active-zone-minutes, heart-rate, heart-rate-variability, weight,
body-fat, daily-resting-heart-rate, daily-heart-rate-variability,
daily-vo2-max, and more.
12. Files Added / Modified
Added:
app/controllers/health_connections_controller.rb
app/controllers/health_controller.rb
app/controllers/health_habit_rules_controller.rb
app/controllers/health_sport_mappings_controller.rb
app/controllers/webhooks/google_health_controller.rb
app/jobs/health/backfill_job.rb
app/jobs/health/sync_connected_users_job.rb
app/jobs/health/sync_job.rb
app/jobs/health/webhook_sync_job.rb
app/models/health.rb
app/models/health/activity_log_projector.rb
app/models/health/connection.rb
app/models/health/data_point.rb
app/models/health/exercise_session.rb
app/models/health/google.rb
app/models/health/google/client.rb
app/models/health/google/configuration.rb
app/models/health/google/daily_summary_importer.rb
app/models/health/google/data_type_registry.rb
app/models/health/google/exercise_importer.rb
app/models/health/google/oauth_client.rb
app/models/health/google/subscriber_manager.rb
app/models/health/google/token_refresher.rb
app/models/health/habit_rule.rb
app/models/health/habit_rule_evaluator.rb
app/models/health/sport_mapping.rb
app/models/health/sync_runner.rb
app/policies/health/connection_policy.rb
app/policies/health/habit_rule_policy.rb
app/policies/health/sport_mapping_policy.rb
app/policies/health_policy.rb
app/views/health/show.html.erb
app/views/health_habit_rules/index.html.erb
app/views/health_sport_mappings/index.html.erb
db/migrate/20260520170801_create_health_connections.rb
db/migrate/20260520170802_create_health_data_points.rb
db/migrate/20260520170803_create_health_exercise_sessions.rb
db/migrate/20260520170804_create_health_sport_mappings.rb
db/migrate/20260520170805_create_health_habit_rules.rb
db/migrate/20260520170806_extend_activity_logs_for_health_imports.rb
db/migrate/20260520170807_add_subscriber_name_to_health_connections.rb
test/controllers/health_controller_test.rb
test/fixtures/health/connections.yml
test/fixtures/health/data_points.yml
test/fixtures/health/exercise_sessions.yml
test/fixtures/health/habit_rules.yml
test/fixtures/health/sport_mappings.yml
test/models/health/activity_log_projector_test.rb
test/models/health/connection_test.rb
test/models/health/habit_rule_evaluator_test.rb
Modified:
AGENTS.md (allowed gems: googleauth)
Gemfile / Gemfile.lock (gem 'googleauth')
app/helpers/application_helper.rb (nav icon: "health")
app/models/activity_log.rb (source enum, optional sport, imported scope)
app/models/user.rb (has_many :health_*)
app/views/shared/_sidebar_links.html.erb (Health nav entry)
config/recurring.yml (health_sync_connected_users)
config/routes.rb (health resources + webhook)
docs/google-health/implementation-plan.md (gem removed, direct REST)
docs/google-health/project-plan.md ("Corrections Applied" header)