diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index 169747551..48dfedfb1 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -75326,6 +75326,36 @@ components:
hasActivityScope:
description: Withings only.
type: boolean
+ planUnavailable:
+ description: Withings only. The readings this connection's Withings API plan withholds (status 277 since the 2026-10-12
+ plan split), deduplicated and in display order. Empty when the plan refuses nothing. The sync skips these
+ and keeps importing everything else; the list refreshes when the weekly re-probe asks again.
+ type: array
+ items:
+ type: string
+ enum:
+ - spo2
+ - vascularAge
+ - pulseWaveVelocity
+ - vo2Max
+ - visceralFat
+ - skinTemperature
+ - ecg
+ - sleepScore
+ - sleepHrv
+ - sleepHeartRate
+ - sleepRespiratoryRate
+ - sleepSpo2
+ - bodyTemperature
+ - bodyFat
+ - fatMass
+ - fatFreeMass
+ - muscleMass
+ - bodyWater
+ - boneMass
+ - heartRate
+ - bloodPressure
+ - weight
needsReauth:
description: Google Health only.
type: boolean
@@ -75335,6 +75365,18 @@ components:
hasOwnCredentials:
description: Oura / Polar / Strava only.
type: boolean
+ scopeReconnect:
+ description: "Oura only. Collections the stored grant does not cover: they answered 401 while sibling collections
+ succeeded with the same token. Not a failure and not counted toward the reconnect threshold; one reconnect
+ asks for the missing scopes. Empty when the grant covers everything HealthLog reads."
+ type: array
+ items:
+ type: string
+ enum:
+ - spo2
+ - vo2max
+ - cardio_age
+ - resilience
hasToken:
description: Nightscout only.
type: boolean
diff --git a/docs/integrations/withings.md b/docs/integrations/withings.md
index 58253d76d..99ddc858a 100644
--- a/docs/integrations/withings.md
+++ b/docs/integrations/withings.md
@@ -25,6 +25,56 @@ Activity data (steps, active energy, distance, floors climbed) lands
via the `user.activity` OAuth scope shipped in v1.4.25. Sleep stages
arrive via the sleep series endpoint.
+## Withings API plans (October 2026)
+
+On 12 October 2026 Withings split its public API into plans
+([plans page](https://static.withings.com/content/api/plans/plans.html)).
+The developer app a self-hoster registers for HealthLog sits on the free
+**Start for Free** plan, which covers the core biomarkers: activity, sleep,
+body and heart. The FDA/CE-cleared biomarkers and a few body-composition
+extras now need the paid **Advanced Biomarkers** plan.
+
+A request that names anything outside the plan is refused as a whole with
+status 277 ("One of the data type requested in this API request is not
+available in your API plan"). Before v1.42.3 HealthLog asked for every type
+in one request, so from that date no Withings reading imported at all, and
+the connection showed "Reauthorisation required" although reconnecting could
+not help.
+
+**Still synced on the free plan:** weight, fat mass and fat-free mass, muscle
+mass, bone mass, body water, blood pressure, heart rate, a manual SpO2
+reading, body temperature, steps, distance and calories, and the sleep
+stages (duration, deep, light, REM, awake, wake-ups).
+
+**Needs the paid Advanced Biomarkers plan** (per Withings'
+[available health data](https://developer.withings.com/developer-guide/v3/data-api/all-available-health-data/)
+table): pulse wave velocity (meastype 91), vascular age (155), visceral fat
+(170), VO2 max (123), skin temperature (73), ECG recordings and AFib
+verdicts, and the sleep score, overnight HRV and overnight breathing rate
+from the sleep summary.
+
+**What HealthLog does about it, automatically:**
+
+- When Withings refuses a request with 277, HealthLog asks again without
+ the types Withings lists as advanced. If that is still refused, it
+ splits the remaining types until it has found the ones the plan
+ refuses, within a fixed number of requests. Everything the plan allows
+ imports as before.
+- The refused types are remembered per connection, so later syncs skip
+ them without a refused request. Once a week HealthLog asks for the full
+ set again, so a plan upgrade is picked up on its own.
+- ECG recordings are skipped quietly while the plan refuses them.
+- A plan refusal is never counted as a sync failure and never asks you to
+ reconnect. Connections that an earlier version parked over it are
+ released on the next hourly sync, and that sync imports everything since
+ the last successful one.
+- Settings → Integrations → Withings shows a short note naming the
+ readings your plan withholds, with a link to the Withings plans page.
+
+Nothing needs to be configured. If you move your developer app to the
+Advanced Biomarkers plan, the extra readings start arriving within a week,
+or right away after reconnecting Withings once.
+
## 1. Register a Withings developer app
1. Sign in at and **create a new
@@ -167,6 +217,11 @@ fixes the mismatch by updating the URL Withings holds.
### Sync is stuck on "Reauthorisation required"
+If this started on or after 12 October 2026 and your version is older than
+v1.42.3, the cause is the API plan change, not your grant: see
+[Withings API plans (October 2026)](#withings-api-plans-october-2026).
+Updating is the fix; reconnecting is not needed.
+
Withings revokes refresh tokens on password change, app un-authorise,
or after extended inactivity. The `WithingsConnection` row flips to
`error_reauth` status and the scheduled sync no-ops until the user
diff --git a/messages/de.json b/messages/de.json
index 118df8494..001763d15 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -6505,6 +6505,47 @@
"body": "Deine Withings-Verbindung wurde autorisiert, bevor der Aktivitäts-Sync hinzugefügt wurde. Verbinde einmal neu, um Schritte, aktive Energie, Geh- und Laufdistanz sowie Stockwerke aus deinen Withings-Geräten zu übernehmen.",
"action": "Withings neu verbinden"
}
+ },
+ "plan": {
+ "title": "Einige Werte brauchen den kostenpflichtigen API-Plan von Withings",
+ "body": "Withings bietet {list} jetzt nur noch im kostenpflichtigen API-Plan an, und deine Withings-Entwickler-App nutzt den kostenlosen Plan. Gewicht, Blutdruck, Körperzusammensetzung, Herzfrequenz und Schlaf werden weiter synchronisiert.",
+ "link": "Mehr zu den API-Plänen von Withings",
+ "items": {
+ "spo2": "SpO2",
+ "vascularAge": "Gefäßalter",
+ "pulseWaveVelocity": "Pulswellengeschwindigkeit",
+ "vo2Max": "VO2max",
+ "visceralFat": "Viszeralfett",
+ "skinTemperature": "Hauttemperatur",
+ "ecg": "EKG und Vorhofflimmern",
+ "sleepScore": "Schlaf-Score",
+ "sleepHrv": "nächtliche HRV",
+ "sleepHeartRate": "nächtliche Herzfrequenz",
+ "sleepRespiratoryRate": "nächtliche Atemfrequenz",
+ "sleepSpo2": "nächtliche SpO2",
+ "bodyTemperature": "Körpertemperatur",
+ "bodyFat": "Körperfett",
+ "fatMass": "Fettmasse",
+ "fatFreeMass": "fettfreie Masse",
+ "muscleMass": "Muskelmasse",
+ "bodyWater": "Körperwasser",
+ "boneMass": "Knochenmasse",
+ "heartRate": "Herzfrequenz",
+ "bloodPressure": "Blutdruck",
+ "weight": "Gewicht"
+ }
+ }
+ },
+ "oura": {
+ "scope": {
+ "body": "Verbinde Oura einmal neu, um {list} freizugeben. Alles andere wird weiter synchronisiert.",
+ "action": "Oura neu verbinden",
+ "items": {
+ "spo2": "SpO2",
+ "vo2max": "VO2max",
+ "cardio_age": "kardiovaskuläres Alter",
+ "resilience": "Resilienz"
+ }
}
}
},
diff --git a/messages/en.json b/messages/en.json
index 7191e99c1..c70292f31 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -6505,6 +6505,47 @@
"body": "Your Withings connection was authorised before activity sync was added. Reconnect once to enable steps, active energy, walking + running distance, and floors-climbed ingest from your Withings devices.",
"action": "Reconnect Withings"
}
+ },
+ "plan": {
+ "title": "Some readings need Withings' paid API plan",
+ "body": "Withings now offers {list} only in its paid API plan, and your Withings developer app is on the free plan. Weight, blood pressure, body composition, heart rate and sleep keep syncing.",
+ "link": "About the Withings API plans",
+ "items": {
+ "spo2": "SpO2",
+ "vascularAge": "vascular age",
+ "pulseWaveVelocity": "pulse wave velocity",
+ "vo2Max": "VO2 max",
+ "visceralFat": "visceral fat",
+ "skinTemperature": "skin temperature",
+ "ecg": "ECG and AFib",
+ "sleepScore": "sleep score",
+ "sleepHrv": "overnight HRV",
+ "sleepHeartRate": "overnight heart rate",
+ "sleepRespiratoryRate": "overnight breathing rate",
+ "sleepSpo2": "overnight SpO2",
+ "bodyTemperature": "body temperature",
+ "bodyFat": "body fat",
+ "fatMass": "fat mass",
+ "fatFreeMass": "fat-free mass",
+ "muscleMass": "muscle mass",
+ "bodyWater": "body water",
+ "boneMass": "bone mass",
+ "heartRate": "heart rate",
+ "bloodPressure": "blood pressure",
+ "weight": "weight"
+ }
+ }
+ },
+ "oura": {
+ "scope": {
+ "body": "Reconnect Oura once to allow {list}. Everything else keeps syncing.",
+ "action": "Reconnect Oura",
+ "items": {
+ "spo2": "SpO2",
+ "vo2max": "VO2 max",
+ "cardio_age": "cardiovascular age",
+ "resilience": "resilience"
+ }
}
}
},
diff --git a/messages/es.json b/messages/es.json
index 7ce217112..a7b2b87ed 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -6505,6 +6505,47 @@
"body": "Tu conexión con Withings se autorizó antes de añadir la sincronización de actividad. Vuelve a conectar una vez para habilitar la importación de pasos, energía activa, distancia caminando y corriendo, y pisos subidos desde tus dispositivos Withings.",
"action": "Reconectar Withings"
}
+ },
+ "plan": {
+ "title": "Algunos valores requieren el plan de API de pago de Withings",
+ "body": "Withings ahora ofrece {list} solo en su plan de API de pago, y tu app de desarrollador de Withings usa el plan gratuito. El peso, la presión arterial, la composición corporal, la frecuencia cardíaca y el sueño se siguen sincronizando.",
+ "link": "Sobre los planes de API de Withings",
+ "items": {
+ "spo2": "SpO2",
+ "vascularAge": "edad vascular",
+ "pulseWaveVelocity": "velocidad de onda de pulso",
+ "vo2Max": "VO2 máx.",
+ "visceralFat": "grasa visceral",
+ "skinTemperature": "temperatura de la piel",
+ "ecg": "ECG y fibrilación auricular",
+ "sleepScore": "puntuación de sueño",
+ "sleepHrv": "VFC nocturna",
+ "sleepHeartRate": "frecuencia cardíaca nocturna",
+ "sleepRespiratoryRate": "frecuencia respiratoria nocturna",
+ "sleepSpo2": "SpO2 nocturna",
+ "bodyTemperature": "temperatura corporal",
+ "bodyFat": "grasa corporal",
+ "fatMass": "masa grasa",
+ "fatFreeMass": "masa libre de grasa",
+ "muscleMass": "masa muscular",
+ "bodyWater": "agua corporal",
+ "boneMass": "masa ósea",
+ "heartRate": "frecuencia cardíaca",
+ "bloodPressure": "presión arterial",
+ "weight": "peso"
+ }
+ }
+ },
+ "oura": {
+ "scope": {
+ "body": "Vuelve a conectar Oura una vez para permitir {list}. Todo lo demás se sigue sincronizando.",
+ "action": "Volver a conectar Oura",
+ "items": {
+ "spo2": "SpO2",
+ "vo2max": "VO2 máx.",
+ "cardio_age": "edad cardiovascular",
+ "resilience": "resiliencia"
+ }
}
}
},
diff --git a/messages/fr.json b/messages/fr.json
index 5181f03ba..b47dc29fe 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -6505,6 +6505,47 @@
"body": "Votre connexion Withings a été autorisée avant l’ajout de la synchronisation d’activité. Reconnectez-vous une fois pour activer l’import des pas, de l’énergie active, de la distance à pied et en course, et des étages gravis depuis vos appareils Withings.",
"action": "Reconnecter Withings"
}
+ },
+ "plan": {
+ "title": "Certaines mesures nécessitent l’offre API payante de Withings",
+ "body": "Withings ne propose désormais {list} que dans son offre API payante, et votre application développeur Withings utilise l’offre gratuite. Le poids, la tension artérielle, la composition corporelle, la fréquence cardiaque et le sommeil continuent d’être synchronisés.",
+ "link": "En savoir plus sur les offres API de Withings",
+ "items": {
+ "spo2": "la SpO2",
+ "vascularAge": "l’âge vasculaire",
+ "pulseWaveVelocity": "la vitesse de l’onde de pouls",
+ "vo2Max": "la VO2 max",
+ "visceralFat": "la graisse viscérale",
+ "skinTemperature": "la température cutanée",
+ "ecg": "l’ECG et la fibrillation auriculaire",
+ "sleepScore": "le score de sommeil",
+ "sleepHrv": "la VFC nocturne",
+ "sleepHeartRate": "la fréquence cardiaque nocturne",
+ "sleepRespiratoryRate": "la fréquence respiratoire nocturne",
+ "sleepSpo2": "la SpO2 nocturne",
+ "bodyTemperature": "la température corporelle",
+ "bodyFat": "le taux de graisse corporelle",
+ "fatMass": "la masse grasse",
+ "fatFreeMass": "la masse maigre",
+ "muscleMass": "la masse musculaire",
+ "bodyWater": "l’eau corporelle",
+ "boneMass": "la masse osseuse",
+ "heartRate": "la fréquence cardiaque",
+ "bloodPressure": "la tension artérielle",
+ "weight": "le poids"
+ }
+ }
+ },
+ "oura": {
+ "scope": {
+ "body": "Reconnectez Oura une fois pour autoriser {list}. Tout le reste continue d’être synchronisé.",
+ "action": "Reconnecter Oura",
+ "items": {
+ "spo2": "la SpO2",
+ "vo2max": "la VO2 max",
+ "cardio_age": "l’âge cardiovasculaire",
+ "resilience": "la résilience"
+ }
}
}
},
diff --git a/messages/it.json b/messages/it.json
index 102b09984..e7231fe3a 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -6505,6 +6505,47 @@
"body": "La tua connessione Withings è stata autorizzata prima dell’aggiunta della sincronizzazione attività. Riconnettiti una volta per abilitare l’importazione di passi, energia attiva, distanza a piedi e di corsa, e piani saliti dai tuoi dispositivi Withings.",
"action": "Riconnetti Withings"
}
+ },
+ "plan": {
+ "title": "Alcuni valori richiedono il piano API a pagamento di Withings",
+ "body": "Withings ora offre {list} solo nel suo piano API a pagamento, e la tua app sviluppatore Withings usa il piano gratuito. Peso, pressione arteriosa, composizione corporea, frequenza cardiaca e sonno continuano a sincronizzarsi.",
+ "link": "Informazioni sui piani API di Withings",
+ "items": {
+ "spo2": "SpO2",
+ "vascularAge": "età vascolare",
+ "pulseWaveVelocity": "velocità dell’onda di polso",
+ "vo2Max": "VO2 max",
+ "visceralFat": "grasso viscerale",
+ "skinTemperature": "temperatura cutanea",
+ "ecg": "ECG e fibrillazione atriale",
+ "sleepScore": "punteggio del sonno",
+ "sleepHrv": "HRV notturna",
+ "sleepHeartRate": "frequenza cardiaca notturna",
+ "sleepRespiratoryRate": "frequenza respiratoria notturna",
+ "sleepSpo2": "SpO2 notturna",
+ "bodyTemperature": "temperatura corporea",
+ "bodyFat": "grasso corporeo",
+ "fatMass": "massa grassa",
+ "fatFreeMass": "massa magra",
+ "muscleMass": "massa muscolare",
+ "bodyWater": "acqua corporea",
+ "boneMass": "massa ossea",
+ "heartRate": "frequenza cardiaca",
+ "bloodPressure": "pressione arteriosa",
+ "weight": "peso"
+ }
+ }
+ },
+ "oura": {
+ "scope": {
+ "body": "Riconnetti Oura una volta per consentire {list}. Tutto il resto continua a sincronizzarsi.",
+ "action": "Riconnetti Oura",
+ "items": {
+ "spo2": "SpO2",
+ "vo2max": "VO2 max",
+ "cardio_age": "età cardiovascolare",
+ "resilience": "resilienza"
+ }
}
}
},
diff --git a/messages/ko.json b/messages/ko.json
index d05ccb066..292fd4013 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -6505,6 +6505,47 @@
"body": "활동 동기화가 추가되기 전에 Withings 연결을 승인했어요. 한 번만 다시 연결하면 Withings 기기에서 걸음 수, 활동 에너지, 걷기·달리기 거리, 오른 층수를 가져올 수 있어요.",
"action": "Withings 다시 연결"
}
+ },
+ "plan": {
+ "title": "일부 측정값은 Withings 유료 API 요금제가 필요해요",
+ "body": "Withings는 이제 {list} 데이터를 유료 API 요금제에서만 제공하고, 사용 중인 Withings 개발자 앱은 무료 요금제예요. 체중, 혈압, 체성분, 심박수, 수면은 계속 동기화돼요.",
+ "link": "Withings API 요금제 알아보기",
+ "items": {
+ "spo2": "SpO2",
+ "vascularAge": "혈관 나이",
+ "pulseWaveVelocity": "맥파 속도",
+ "vo2Max": "VO2 max",
+ "visceralFat": "내장 지방",
+ "skinTemperature": "피부 온도",
+ "ecg": "심전도와 심방세동",
+ "sleepScore": "수면 점수",
+ "sleepHrv": "야간 HRV",
+ "sleepHeartRate": "야간 심박수",
+ "sleepRespiratoryRate": "야간 호흡수",
+ "sleepSpo2": "야간 SpO2",
+ "bodyTemperature": "체온",
+ "bodyFat": "체지방률",
+ "fatMass": "체지방량",
+ "fatFreeMass": "제지방량",
+ "muscleMass": "근육량",
+ "bodyWater": "체수분",
+ "boneMass": "골량",
+ "heartRate": "심박수",
+ "bloodPressure": "혈압",
+ "weight": "체중"
+ }
+ }
+ },
+ "oura": {
+ "scope": {
+ "body": "{list} 데이터를 허용하려면 Oura를 한 번 다시 연결하세요. 나머지는 계속 동기화돼요.",
+ "action": "Oura 다시 연결",
+ "items": {
+ "spo2": "SpO2",
+ "vo2max": "VO2 max",
+ "cardio_age": "심혈관 나이",
+ "resilience": "회복탄력성"
+ }
}
}
},
diff --git a/messages/pl.json b/messages/pl.json
index a8d64dd6c..c22982d53 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -6505,6 +6505,47 @@
"body": "Twoje połączenie z Withings zostało autoryzowane przed dodaniem synchronizacji aktywności. Połącz ponownie jeden raz, aby włączyć import kroków, energii aktywnej, dystansu marszu i biegu oraz pokonanych pięter z urządzeń Withings.",
"action": "Połącz ponownie Withings"
}
+ },
+ "plan": {
+ "title": "Niektóre pomiary wymagają płatnego planu API Withings",
+ "body": "Withings udostępnia teraz te dane tylko w płatnym planie API: {list}. Twoja aplikacja deweloperska Withings korzysta z bezpłatnego planu. Waga, ciśnienie krwi, skład ciała, tętno i sen są nadal synchronizowane.",
+ "link": "Więcej o planach API Withings",
+ "items": {
+ "spo2": "SpO2",
+ "vascularAge": "wiek naczyniowy",
+ "pulseWaveVelocity": "prędkość fali tętna",
+ "vo2Max": "VO2 max",
+ "visceralFat": "tłuszcz trzewny",
+ "skinTemperature": "temperatura skóry",
+ "ecg": "EKG i migotanie przedsionków",
+ "sleepScore": "wynik snu",
+ "sleepHrv": "nocne HRV",
+ "sleepHeartRate": "nocne tętno",
+ "sleepRespiratoryRate": "nocna częstość oddechów",
+ "sleepSpo2": "nocne SpO2",
+ "bodyTemperature": "temperatura ciała",
+ "bodyFat": "tkanka tłuszczowa",
+ "fatMass": "masa tłuszczowa",
+ "fatFreeMass": "beztłuszczowa masa ciała",
+ "muscleMass": "masa mięśniowa",
+ "bodyWater": "woda w organizmie",
+ "boneMass": "masa kostna",
+ "heartRate": "tętno",
+ "bloodPressure": "ciśnienie krwi",
+ "weight": "waga"
+ }
+ }
+ },
+ "oura": {
+ "scope": {
+ "body": "Połącz ponownie Oura jeden raz, aby zezwolić na te dane: {list}. Wszystko inne jest nadal synchronizowane.",
+ "action": "Połącz ponownie Oura",
+ "items": {
+ "spo2": "SpO2",
+ "vo2max": "VO2 max",
+ "cardio_age": "wiek sercowo-naczyniowy",
+ "resilience": "odporność"
+ }
}
}
},
diff --git a/prisma/migrations/0398_integration_plan_and_scope_state/migration.sql b/prisma/migrations/0398_integration_plan_and_scope_state/migration.sql
new file mode 100644
index 000000000..7a889de3e
--- /dev/null
+++ b/prisma/migrations/0398_integration_plan_and_scope_state/migration.sql
@@ -0,0 +1,18 @@
+-- v1.42.3 — remember what an integration's upstream refuses by contract.
+--
+-- Withings split its public API into plans on 2026-10-12. A self-hoster's own
+-- developer app sits on the free plan, and a request that names a meastype
+-- outside that plan is refused whole with status 277. The sync now drops the
+-- gated types and keeps the rest; this column remembers which ones per
+-- connection so later runs skip them without a refused round trip, plus when
+-- that was last checked so a plan upgrade is noticed within a week.
+--
+-- Oura's current authorization server enforces scopes per collection. A grant
+-- issued before HealthLog asked for `spo2` and `heart_health` answers 401 on
+-- the collections behind them while every other collection succeeds. This
+-- column holds those collection names so the Oura card can ask for a single
+-- reconnect instead of painting the connection as broken.
+--
+-- Additive only: two nullable columns, no default, no rewrite, no data change.
+ALTER TABLE "withings_connections" ADD COLUMN IF NOT EXISTS "api_plan_state" JSONB;
+ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "oura_scope_denied" JSONB;
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 20b178b71..9925b8ecf 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -264,6 +264,13 @@ model User {
// `src/lib/crypto.ts`, matching every other `*Encrypted` column.
ouraClientIdEncrypted String? @map("oura_client_id_encrypted")
ouraClientSecretEncrypted String? @map("oura_client_secret_encrypted")
+ /// v1.42.3 — Oura collections the current grant does not cover, as a JSON
+ /// string array of collection names (`spo2`, `vo2max`, `cardio_age`,
+ /// `resilience`). Written by the sync when a collection answers 401/403 while
+ /// its siblings succeed with the same token, which is a scope the grant never
+ /// carried, not a dead token. The Oura card reads it to ask for one
+ /// reconnect. Cleared on connect, disconnect and credential removal.
+ ouraScopeDenied Json? @map("oura_scope_denied")
// v1.28.x — Strava OAuth credentials (per-user BYO-app, encrypted at app
// level). Strava caps every newly-created API app at single-player mode
@@ -4149,6 +4156,13 @@ model WithingsConnection {
/// earliest transient/pending category retry for indexed hourly discovery.
webhookSubscriptionState Json? @map("webhook_subscription_state")
webhookSubscriptionRetryAt DateTime? @map("webhook_subscription_retry_at")
+ /// v1.42.3 — what this connection's Withings API plan refuses (status 277),
+ /// per request family: `{ measures, sleepSummary, ecg }`, each
+ /// `{ unavailable, checkedAt }`. Lets the sync skip gated meastypes without
+ /// paying a refused round trip each time, re-probe them weekly, and lets the
+ /// Withings card name what the plan withholds. Versioned and validated on
+ /// read (`src/lib/withings/api-plan.ts`); reset on reconnect.
+ apiPlanState Json? @map("api_plan_state")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts
index 49f7a32d9..dfd9cbdf4 100644
--- a/src/__tests__/i18n-english-leak-guard.test.ts
+++ b/src/__tests__/i18n-english-leak-guard.test.ts
@@ -103,6 +103,8 @@ const LEGIT_IDENTICAL = new Set([
// "{hours} h {minutes} min": the SI symbols for hour and minute.
"measurements.durationHoursMinutes",
"settings.sections.sources.metrics.vo2Max",
+ "settings.integrations.withings.plan.items.vo2Max",
+ "settings.integrations.oura.scope.items.vo2max",
// Brand / product / proper nouns.
"settings.googleHealth",
"admin.glitchtipDsn",
diff --git a/src/app/api/integrations/status/__tests__/route.test.ts b/src/app/api/integrations/status/__tests__/route.test.ts
index 1c0dde365..25b08cc78 100644
--- a/src/app/api/integrations/status/__tests__/route.test.ts
+++ b/src/app/api/integrations/status/__tests__/route.test.ts
@@ -52,7 +52,12 @@ const ouraAvailable = vi.fn(async () => false);
vi.mock("@/lib/polar/credentials", () => ({
getPolarClientCredentials: () => polarAvailable(),
}));
-vi.mock("@/lib/oura/credentials", () => ({
+vi.mock("@/lib/oura/credentials", async () => ({
+ readOuraScopeDenied: (
+ await vi.importActual(
+ "@/lib/oura/credentials",
+ )
+ ).readOuraScopeDenied,
getOuraClientCredentials: () => ouraAvailable(),
}));
@@ -387,3 +392,55 @@ describe("/api/integrations/status — a provider with no ledger row", () => {
expect(whoop.syncHealth?.verdict).toBe("disconnected");
});
});
+
+describe("/api/integrations/status — what a provider withholds by contract", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("names the readings the Withings plan withholds and the Oura collections the grant lacks", async () => {
+ (
+ prisma.withingsConnection.findUnique as ReturnType
+ ).mockResolvedValueOnce({
+ tokenExpiresAt: new Date(Date.now() + 3_600_000),
+ lastSyncedAt: null,
+ createdAt: new Date(),
+ scope: "user.metrics,user.activity",
+ apiPlanState: {
+ version: 1,
+ measures: {
+ unavailable: [155, 54],
+ checkedAt: new Date().toISOString(),
+ },
+ ecg: { unavailable: ["heart"], checkedAt: new Date().toISOString() },
+ },
+ });
+ userFind.mockResolvedValue({
+ ouraAccessTokenEncrypted: "tok",
+ ouraScopeDenied: ["resilience", "spo2", "not_a_collection"],
+ });
+
+ const entries = (await fetchEntries()) as Array<
+ Entry & { planUnavailable?: string[]; scopeReconnect?: string[] }
+ >;
+ const withings = entries.find((e) => e.integration === "withings")!;
+ const oura = entries.find((e) => e.integration === "oura")!;
+
+ expect(withings.planUnavailable).toEqual(["spo2", "vascularAge", "ecg"]);
+ expect(oura.scopeReconnect).toEqual(["spo2", "resilience"]);
+ });
+
+ it("publishes empty lists when nothing is withheld", async () => {
+ userFind.mockResolvedValue({ ouraAccessTokenEncrypted: "tok" });
+
+ const entries = (await fetchEntries()) as Array<
+ Entry & { planUnavailable?: string[]; scopeReconnect?: string[] }
+ >;
+ expect(
+ entries.find((e) => e.integration === "withings")!.planUnavailable,
+ ).toEqual([]);
+ expect(
+ entries.find((e) => e.integration === "oura")!.scopeReconnect,
+ ).toEqual([]);
+ });
+});
diff --git a/src/app/api/integrations/status/route.ts b/src/app/api/integrations/status/route.ts
index af6c7243c..fe97a54a2 100644
--- a/src/app/api/integrations/status/route.ts
+++ b/src/app/api/integrations/status/route.ts
@@ -35,7 +35,19 @@ import {
resolveSyncVerdict,
type SyncHealth,
} from "@/lib/integrations/sync-verdict";
-import { getOuraClientCredentials } from "@/lib/oura/credentials";
+import {
+ getOuraClientCredentials,
+ readOuraScopeDenied,
+} from "@/lib/oura/credentials";
+import {
+ ouraScopeReconnectList,
+ type OuraScopeGatedCollection,
+} from "@/lib/oura/scope";
+import { parseWithingsApiPlanState } from "@/lib/withings/api-plan";
+import {
+ withingsPlanUnavailableFeatures,
+ type WithingsPlanFeature,
+} from "@/lib/withings/plan-features";
import { getPolarClientCredentials } from "@/lib/polar/credentials";
import { getStravaClientCredentials } from "@/lib/strava/credentials";
import { hasActivityScope } from "@/lib/withings/client";
@@ -92,6 +104,7 @@ export const GET = apiHandler(async () => {
ouraAccessTokenEncrypted: true,
ouraClientIdEncrypted: true,
ouraClientSecretEncrypted: true,
+ ouraScopeDenied: true,
stravaAccessTokenEncrypted: true,
stravaClientIdEncrypted: true,
stravaClientSecretEncrypted: true,
@@ -109,6 +122,7 @@ export const GET = apiHandler(async () => {
lastSyncedAt: true,
createdAt: true,
scope: true,
+ apiPlanState: true,
},
}),
prisma.whoopConnection.findUnique({
@@ -280,6 +294,13 @@ export const GET = apiHandler(async () => {
// Null `scope` = legacy connection that predates activity-scope reads.
scope: withingsConn?.scope ?? null,
hasActivityScope: hasActivityScope(withingsConn?.scope ?? null),
+ // What this connection's Withings API plan withholds (status 277),
+ // resolved here so the card only renders it.
+ planUnavailable: withingsConn
+ ? withingsPlanUnavailableFeatures(
+ parseWithingsApiPlanState(withingsConn.apiPlanState),
+ )
+ : [],
...resolve("withings", withingsStatus, {
connected: !!withingsConn,
configured: withingsConfigured,
@@ -374,11 +395,17 @@ export const GET = apiHandler(async () => {
hasOwnCredentials:
!!dbUser?.ouraClientIdEncrypted &&
!!dbUser?.ouraClientSecretEncrypted,
+ // Collections the stored grant does not cover; one reconnect asks for
+ // the missing scopes. Not a failure, so it never colours the pill.
+ scopeReconnect: ouraConnected
+ ? ouraScopeReconnectList(readOuraScopeDenied(dbUser?.ouraScopeDenied))
+ : [],
...resolve("oura", ouraStatus, {
connected: ouraConnected,
configured: ouraConnected,
}),
- } satisfies IntegrationViewModel & OAuthProviderExtras,
+ } satisfies IntegrationViewModel &
+ OAuthProviderExtras & { scopeReconnect: OuraScopeGatedCollection[] },
{
...publish(stravaStatus),
connected: stravaConnected,
@@ -442,6 +469,7 @@ interface WithingsExtras {
tokenExpired: boolean | null;
scope: string | null;
hasActivityScope: boolean;
+ planUnavailable: WithingsPlanFeature[];
}
interface WhoopExtras {
diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts
index 89f4284ba..b23802541 100644
--- a/src/app/api/oura/callback/route.ts
+++ b/src/app/api/oura/callback/route.ts
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
+import { Prisma } from "@/generated/prisma/client";
import { apiHandler } from "@/lib/api-handler";
import { getSession } from "@/lib/auth/session";
import { annotate, getEvent } from "@/lib/logging/context";
@@ -87,6 +88,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
data: {
ouraAccessTokenEncrypted: encrypt(tokens.access_token),
ouraRefreshTokenEncrypted: encrypt(tokens.refresh_token),
+ // A new grant carries the scopes asked for now; whatever the old one
+ // lacked is found out again by the first sync.
+ ouraScopeDenied: Prisma.DbNull,
},
});
diff --git a/src/app/api/oura/credentials/route.ts b/src/app/api/oura/credentials/route.ts
index b5bb0a6e9..d6b696c67 100644
--- a/src/app/api/oura/credentials/route.ts
+++ b/src/app/api/oura/credentials/route.ts
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
+import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
@@ -96,6 +97,7 @@ export const DELETE = apiHandler(async () => {
data: {
ouraAccessTokenEncrypted: null,
ouraRefreshTokenEncrypted: null,
+ ouraScopeDenied: Prisma.DbNull,
},
});
await clearOuraClientCredentials(user.id);
diff --git a/src/app/api/oura/disconnect/route.ts b/src/app/api/oura/disconnect/route.ts
index a5daf5f97..56b4e3e8c 100644
--- a/src/app/api/oura/disconnect/route.ts
+++ b/src/app/api/oura/disconnect/route.ts
@@ -1,4 +1,5 @@
import { prisma } from "@/lib/db";
+import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError } from "@/lib/api-response";
@@ -37,6 +38,7 @@ export const POST = apiHandler(async () => {
data: {
ouraAccessTokenEncrypted: null,
ouraRefreshTokenEncrypted: null,
+ ouraScopeDenied: Prisma.DbNull,
},
});
diff --git a/src/app/api/withings/callback/route.ts b/src/app/api/withings/callback/route.ts
index ffabfb882..122d8b6af 100644
--- a/src/app/api/withings/callback/route.ts
+++ b/src/app/api/withings/callback/route.ts
@@ -190,6 +190,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
scope: WITHINGS_OAUTH_SCOPE,
webhookSubscriptionState: Prisma.DbNull,
webhookSubscriptionRetryAt: subscriptionRetryAt,
+ // A reconnect may come with another developer app (another plan), so
+ // what the old grant's plan refused is asked for again.
+ apiPlanState: Prisma.DbNull,
},
create: {
userId: user.id,
diff --git a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx
index 20b99c393..12462e77d 100644
--- a/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx
+++ b/src/components/settings/integrations/__tests__/oauth-provider-card.test.tsx
@@ -232,3 +232,43 @@ describe("OAuthProviderCard — sync-now action", () => {
).not.toContain('data-testid="polar-sync"');
});
});
+
+describe("OAuthProviderCard — Oura scope note", () => {
+ const scopeGap = {
+ connected: true,
+ configured: true,
+ available: true,
+ state: "connected" as const,
+ lastSuccessAt: "2026-10-14T00:00:00.000Z",
+ lastError: null,
+ syncHealth: { verdict: "fresh" as const, since: null },
+ scopeReconnect: ["spo2" as const, "vo2max" as const],
+ };
+
+ function renderOura(viewModel: ViewModel) {
+ return renderToStaticMarkup(
+
+
+ ,
+ );
+ }
+
+ it("asks for one reconnect beside a healthy pill, not an error", () => {
+ const html = renderOura(scopeGap);
+ expect(html).toContain('data-testid="oura-scope-note"');
+ expect(html).toContain("Reconnect Oura once to allow SpO2 and VO2 max.");
+ expect(html).not.toContain('data-testid="oura-error"');
+ });
+
+ it("stays away from the other OAuth providers", () => {
+ expect(render({ viewModel: scopeGap })).not.toContain("scope-note");
+ });
+});
diff --git a/src/components/settings/integrations/__tests__/plan-and-scope-notes.test.tsx b/src/components/settings/integrations/__tests__/plan-and-scope-notes.test.tsx
new file mode 100644
index 000000000..6a109ab19
--- /dev/null
+++ b/src/components/settings/integrations/__tests__/plan-and-scope-notes.test.tsx
@@ -0,0 +1,65 @@
+/**
+ * The two "withheld by contract" notes: the Withings card names what the
+ * account's API plan refuses, the Oura card asks for one reconnect when the
+ * grant lacks a scope. Both render nothing when nothing is withheld, and both
+ * render the server-resolved list rather than deriving one.
+ */
+import { describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { WithingsPlanNote } from "../withings-plan-note";
+import { OuraScopeNote } from "../oura-scope-note";
+
+function render(node: React.ReactNode, locale: "en" | "de" = "en") {
+ return renderToStaticMarkup(
+ {node},
+ );
+}
+
+describe("", () => {
+ it("lists the withheld readings and links the Withings plans page", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-testid="withings-plan-note"');
+ expect(html).toContain("SpO2, vascular age, and ECG and AFib");
+ expect(html).toContain("Weight, blood pressure, body composition");
+ expect(html).toContain(
+ "https://static.withings.com/content/api/plans/plans.html",
+ );
+ });
+
+ it("speaks the reader's language", () => {
+ const html = render(, "de");
+ expect(html).toContain("Gefäßalter");
+ expect(html).toContain("kostenpflichtigen API-Plan");
+ });
+
+ it("renders nothing when the plan withholds nothing", () => {
+ expect(render()).toBe("");
+ expect(render()).toBe("");
+ });
+});
+
+describe("", () => {
+ it("asks for one reconnect naming the collections the grant lacks", () => {
+ const html = render(
+ ,
+ );
+ expect(html).toContain('data-testid="oura-scope-note"');
+ expect(html).toContain(
+ "Reconnect Oura once to allow SpO2, VO2 max, cardiovascular age, and resilience.",
+ );
+ expect(html).toContain('data-testid="oura-scope-reconnect"');
+ });
+
+ it("renders nothing when the grant covers everything", () => {
+ expect(
+ render(),
+ ).toBe("");
+ });
+});
diff --git a/src/components/settings/integrations/oauth-provider-card.tsx b/src/components/settings/integrations/oauth-provider-card.tsx
index 74d4bbad9..2811fd460 100644
--- a/src/components/settings/integrations/oauth-provider-card.tsx
+++ b/src/components/settings/integrations/oauth-provider-card.tsx
@@ -54,6 +54,7 @@ import {
queryKeys,
} from "@/lib/query-keys";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
+import { OuraScopeNote } from "./oura-scope-note";
import {
readSyncOutcome,
useSyncOutcomeMessage,
@@ -72,6 +73,7 @@ import {
type IntegrationDocsProvider,
} from "./setup-guide-link";
import type { IntegrationCallbackUrls } from "@/lib/integrations/callback-urls";
+import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
import { loadDocument } from "@/lib/navigation/load-document";
export interface OAuthProviderStatus {
@@ -99,6 +101,8 @@ export interface OAuthProviderStatus {
/** The server-resolved liveness verdict; the pill is a projection of it. */
syncHealth?: SyncHealth;
metricFreshness?: MetricFreshnessEntry[];
+ /** Oura: collections the stored grant does not cover. */
+ scopeReconnect?: OuraScopeGatedCollection[];
}
export interface OAuthProviderCardProps {
@@ -330,6 +334,14 @@ export function OAuthProviderCard({
)}
+ {provider === "oura" && status?.connected && (
+
+ )}
+
{status?.connected && (
void;
+ disabled?: boolean;
+}) {
+ const { t, locale } = useTranslations();
+ if (!collections || collections.length === 0) return null;
+
+ const labels: Record = {
+ spo2: t("settings.integrations.oura.scope.items.spo2"),
+ vo2max: t("settings.integrations.oura.scope.items.vo2max"),
+ cardio_age: t("settings.integrations.oura.scope.items.cardio_age"),
+ resilience: t("settings.integrations.oura.scope.items.resilience"),
+ };
+ const list = new Intl.ListFormat(locale, {
+ style: "long",
+ type: "conjunction",
+ }).format(collections.map((c) => labels[c]));
+
+ return (
+
+
+ {t("settings.integrations.oura.scope.body", { list })}
+
+
+
+ );
+}
diff --git a/src/components/settings/integrations/shared.tsx b/src/components/settings/integrations/shared.tsx
index 61f759367..396a7b97d 100644
--- a/src/components/settings/integrations/shared.tsx
+++ b/src/components/settings/integrations/shared.tsx
@@ -18,6 +18,8 @@ import type {
SyncVerdict,
} from "@/lib/integrations/sync-verdict";
import { queryKeys } from "@/lib/query-keys";
+import type { WithingsPlanFeature } from "@/lib/withings/plan-features";
+import type { OuraScopeGatedCollection } from "@/lib/oura/scope";
// v1.4.15 Phase B2: shared status payload for both integration cards.
// v1.4.19 Phase A5: the redundant in-card status banner is gone — the
@@ -70,6 +72,10 @@ export interface IntegrationStatusViewModel {
// Withings activity-scope reconnect banner.
scope?: string | null;
hasActivityScope?: boolean;
+ // Withings: readings the account's API plan withholds (server-resolved).
+ planUnavailable?: WithingsPlanFeature[];
+ // Oura: collections the stored grant does not cover (server-resolved).
+ scopeReconnect?: OuraScopeGatedCollection[];
// WHOOP / Fitbit backfill-in-progress note.
backfillCompleted?: boolean | null;
// v1.27.0 — Google Health surfaces `needsReauth` when the refresh token has
diff --git a/src/components/settings/integrations/withings-card.tsx b/src/components/settings/integrations/withings-card.tsx
index 57111589a..b58e7f9da 100644
--- a/src/components/settings/integrations/withings-card.tsx
+++ b/src/components/settings/integrations/withings-card.tsx
@@ -49,6 +49,7 @@ import {
type IntegrationStatusViewModel,
} from "./shared";
import { MetricFreshnessDisclosure } from "./metric-freshness-disclosure";
+import { WithingsPlanNote } from "./withings-plan-note";
import {
readSyncOutcome,
useSyncOutcomeMessage,
@@ -316,6 +317,10 @@ export function WithingsCard({