diff --git a/docs.json b/docs.json
index dbde08a..6037bac 100644
--- a/docs.json
+++ b/docs.json
@@ -88,6 +88,7 @@
"guides/freemium-model",
"guides/onboarding",
"guides/athlete-onboarding-journey",
+ "guides/fueling-profile",
"guides/safety"
]
},
diff --git a/guides/athlete-onboarding-journey.mdx b/guides/athlete-onboarding-journey.mdx
index be78a8a..29f056f 100644
--- a/guides/athlete-onboarding-journey.mdx
+++ b/guides/athlete-onboarding-journey.mdx
@@ -48,14 +48,19 @@ On the last answer, the page asks: *"Can we show you fuel for one of your activi
## The app path, narrated
-Some athletes use, or will prefer, the Saturday app. When their app account email matches the email you sent on the athlete record, Saturday links them and their app onboarding feeds your calculations, resolved live rather than synced, with nothing for you to build. Their app answers are never exposed to you through the API. If they turn on "share my fueling profile with \{your platform\}" in app settings, the profile values become visible to you as well.
+Some athletes use, or will prefer, the Saturday app. When their app account email matches the email you sent on the athlete record, Saturday emails their Saturday address asking them to confirm the connection to your platform. Once they confirm, their app onboarding feeds your calculations, resolved live rather than synced, with nothing for you to build.
-*Tell your athletes:* "use the same email as your \{platform\} account". Matching is exact apart from case and whitespace, so a different address never links, and no error surfaces anywhere for you to catch.
+The answers themselves stay with Saturday until the athlete chooses to share them with you. In the app, Preferences shows a Partner Sharing card with one switch per connected partner platform: "Share my fueling profile with \{your platform\}". On the hosted page, the finish screen asks "Let \{your platform\} see your fueling answers?" and the athlete confirms from an email. Nothing is shared by default, under-18 athletes cannot share, and sharing stays on until they turn it off or disconnect. While it is on, you can read their sex, age, weight and fueling answers.
+
+*Tell your athletes:* "use the same email as your \{platform\} account, and confirm the email Saturday sends you". Matching is exact apart from case and whitespace, so a different address never links, and no error surfaces anywhere for you to catch.
+
+*Your code:* nothing for the numbers. To read the answers, follow [Reading an athlete's fueling profile](/guides/fueling-profile): check `profile_sharing`, handle `athlete.profile_sharing_changed` and `athlete.fueling_profile_updated`, and read `GET /v1/athletes/{athlete_id}/fueling-profile` while it is `on`.
## What to build, minimally
1. Show `precision.message` and the invite button whenever `profile_complete` is false.
2. Register `activity_link_template` and `fueling_path_copy` once.
3. Handle `athlete.profile_completed`, if you want to mark the moment in your UI.
+4. Handle `athlete.profile_sharing_changed`, if you want the athlete's Saturday app answers.
Saturday handles the questions, the hosted page, the linking, and the checkout.
diff --git a/guides/athletes.mdx b/guides/athletes.mdx
index 6912be8..deb61f3 100644
--- a/guides/athletes.mdx
+++ b/guides/athletes.mdx
@@ -83,7 +83,7 @@ Every field is optional at create time. The athlete's fueling profile is what ma
| `partner_plan` | string | Set to `annual` to assert bundle-offer eligibility |
| `org_id` | string | Organization affiliation; must reference an org you created |
-Saturday sets `id`, `partner_id`, `created_at`, `updated_at`, `profile_complete`, and `subscription_status`. Writes to those are ignored.
+Saturday sets `id`, `partner_id`, `created_at`, `updated_at`, `profile_complete`, `subscription_status`, and `profile_sharing`. Writes to those are ignored. `subscription_status` (`full`, `trial`, or `teaser`) and `profile_sharing` (`on`, `off`, or `not_linked`) are computed when you read one athlete with `GET /v1/athletes/{id}`, and are not on lists. `profile_sharing` says whether the athlete shares their Saturday app answers with you; see [Reading an athlete's fueling profile](/guides/fueling-profile).
### Settings: the fueling profile
diff --git a/guides/coach-connector.mdx b/guides/coach-connector.mdx
index 113957d..7062868 100644
--- a/guides/coach-connector.mdx
+++ b/guides/coach-connector.mdx
@@ -34,7 +34,7 @@ The connector speaks the MCP Streamable HTTP transport (`POST /mcp`) and protoco
## Athlete tools
-When an athlete connects, Claude can read and write that athlete's Saturday data. Athlete tools operate strictly on the signed-in athlete; there is no athlete selector. The full catalog is in [MCP Integration](/guides/mcp-integration#tool-catalog). Highlights:
+When an athlete connects, Claude can read and write that athlete's Saturday data. Athlete tools operate strictly on the signed-in athlete; there is no athlete selector. The full catalog is in [MCP Integration](/guides/mcp-integration#tool-catalog), less `get_fueling_profile`, which is for partner platforms. Highlights:
- `get_athlete`, `update_athlete` read and update the athlete's profile.
- `list_activities`, `get_activity`, `create_activity` manage the athlete's activities.
diff --git a/guides/freemium-model.mdx b/guides/freemium-model.mdx
index 745e6f7..88dee52 100644
--- a/guides/freemium-model.mdx
+++ b/guides/freemium-model.mdx
@@ -162,13 +162,13 @@ Give Saturday a `return_url` (https or deep link) at partner onboarding and the
### Automatic linking by email
-Some paying Saturday athletes never touch your subscribe CTA: they subscribed **inside the Saturday app** (Apple/Google in-app purchase) or on saturday.fit before joining your platform. Saturday links these automatically: when the `email` you set on an athlete exactly matches a Saturday account's email (case-insensitive; no fuzzy matching), the records are linked and the athlete's API responses unlock.
+Some paying Saturday athletes never touch your subscribe CTA: they subscribed **inside the Saturday app** (Apple/Google in-app purchase) or on saturday.fit before joining your platform. Saturday links these by email: when the `email` you set on an athlete exactly matches a Saturday account's email (case-insensitive; no fuzzy matching), Saturday emails that account's verified address asking the athlete to confirm the connection to your platform, and once they confirm, the records are linked and the athlete's API responses unlock.
- **Opt in by supplying emails.** Set `email` on your athletes (`POST /v1/athletes` and `PATCH /v1/athletes/{athlete_id}`). Matching runs when you write an athlete email, when a Saturday subscription activates, and in a nightly sweep.
- **You get the same webhook.** When a match links an actively paying account, `subscription.created` fires with `source: "email_match"`. Handle it like a checkout unlock.
- **Ambiguity never auto-links.** Multiple athletes sharing an email, or a conflict with an existing link, goes to Saturday-side human review instead.
- **No revenue share on matched links.** These subscriptions were not driven through your platform (many predate it), so they do not appear on your revenue statement.
-- **Only entitlement is released.** The match tells your platform the athlete's subscription tier, and an expiry when one applies, never payment details, purchase history, or Saturday profile data.
+- **Only entitlement is released.** The match tells your platform the athlete's subscription tier, and an expiry when one applies, never payment details, purchase history, or Saturday profile data. The athlete's Saturday app answers reach you only if they choose to share them; see [Reading an athlete's fueling profile](/guides/fueling-profile).
If you have a partner-negotiated offer with Saturday (e.g. member pricing for your annual subscribers), assert eligibility by setting `partner_plan: "annual"` on the athlete (`PATCH /v1/athletes/{id}`). Eligible athletes' CTAs carry the offer claim and checkout prices accordingly. You may advertise the offer anywhere; only asserted athletes can redeem it.
diff --git a/guides/fueling-profile.mdx b/guides/fueling-profile.mdx
new file mode 100644
index 0000000..22ebc33
--- /dev/null
+++ b/guides/fueling-profile.mdx
@@ -0,0 +1,257 @@
+---
+title: "Reading an athlete's fueling profile"
+description: "The answers an athlete gave in the Saturday app, when they choose to share them with your platform"
+sidebarTitle: 'Shared Fueling Profile'
+icon: 'share-nodes'
+---
+
+When an athlete on your platform has connected a Saturday account, Saturday's calculations for that athlete use their Saturday app answers to fill anything you have not stored. Seeing those answers yourself is a separate step, and the athlete decides it: one choice per partner platform, off until they turn it on. This page is the narrative first, written so a developer or an AI assistant building your integration knows what to do in each state, then the reference.
+
+The answers here are the athlete's Saturday app answers, held on their Saturday account. They are not the `settings` you store on your athlete record, which you read with `GET /v1/athletes/{athlete_id}/settings` (see [Athletes](/guides/athletes)).
+
+## State machine
+
+Every athlete on your platform is in exactly one sharing state. `GET /v1/athletes/{athlete_id}` carries it as `profile_sharing`, and `GET /v1/athletes/{athlete_id}/fueling-profile` carries it as `sharing`.
+
+| State | What it means | What your code does |
+|---|---|---|
+| `not_linked` | No Saturday account is connected to this athlete record | Nothing to read. Calculations use what you store. |
+| `off` | A Saturday account is connected, and the athlete has not chosen to share their answers with you | Do not call the route on a timer. Show what you store. Wait for `athlete.profile_sharing_changed`. |
+| `on` | The athlete shares their answers with you | Read `GET /v1/athletes/{athlete_id}/fueling-profile`. Read it again on `athlete.fueling_profile_updated`. |
+
+What moves an athlete between states, and the webhook each move sends:
+
+| Move | Cause | Webhook |
+|---|---|---|
+| `not_linked` → `off` | The athlete confirms the connection to their Saturday account | None from sharing. `GET /v1/athletes/{athlete_id}` shows `off`. |
+| `off` → `on` | The athlete turns sharing on in the Saturday app, or confirms the hosted page's email | `athlete.profile_sharing_changed`, record shows `profile_sharing: "on"` |
+| `on` → `off` | The athlete turns sharing off in the app or on the hosted page, or their account now reads as under 18 | `athlete.profile_sharing_changed`, record shows `profile_sharing: "off"` |
+| `on` → `not_linked` | The connection to the Saturday account goes | `athlete.profile_sharing_changed`, record shows `profile_sharing: "not_linked"` |
+| `not_linked` → `on` | A connection arrives for a Saturday account that shares with you | `athlete.profile_sharing_changed`, record shows `profile_sharing: "on"` |
+| `on`, answers change | The athlete changes a shared answer in the Saturday app | `athlete.fueling_profile_updated`, naming the fields |
+
+An athlete's account connects to your athlete record through a Saturday checkout link they open from your platform, or through an exact email match that the athlete confirms from an email Saturday sends to their Saturday address. See [Freemium Model, Automatic linking by email](/guides/freemium-model#automatic-linking-by-email).
+
+## What the athlete sees
+
+- **In the Saturday app.** Preferences shows a Partner Sharing card once their Saturday account is connected to at least one partner platform, with one switch per connected platform: "Share my fueling profile with \{your platform\}". The line under the switch lists what you see when it is on and says their numbers on your platform stay the same either way. A switch takes effect at once, on or off.
+- **On Saturday's hosted onboarding page.** The finish screen asks "Let \{your platform\} see your fueling answers?" when the athlete's Saturday account is connected to your platform, the athlete is 18 or older, and the account has a verified email address. Yes emails that address a one-tap confirmation, and sharing starts when the athlete taps it. No turns sharing off at once. An athlete who shares sees a Stop sharing button instead.
+- **Under 18.** No switch in the app and no question on the hosted page. You see `off`, with the same message as any athlete who has not shared, so a response never reveals an age.
+- **No expiry.** Sharing stays on until the athlete turns it off, the accounts are disconnected, or the Saturday account is deleted.
+
+The hosted page's Yes needs the email step because you hold the hosted page's link and send it yourself, so a tap on that page alone cannot show that the athlete made the choice. The email goes to the athlete's Saturday address, which you do not control. Its link works for 7 days. Tapping Yes again sends a new link, at most one every 10 minutes and three in all, until the request expires.
+
+## The journey, narrated
+
+**1. You create the athlete with their email.**
+They use the Saturday app under the same address. Saturday emails that address asking them to confirm the connection to your platform. Until they confirm, `profile_sharing` is `not_linked`.
+
+*Your code:* nothing. Calculations use what you store.
+
+**2. They confirm the connection.**
+Their Saturday app answers start filling the gaps in what you store, at calculation time. `profile_sharing` reads `off`: they have not chosen to share the answers themselves.
+
+*Your code:* if you want the answers, tell the athlete they can share them with you from the Saturday app or from Saturday's hosted onboarding page. Do not poll the route.
+
+**3. They turn sharing on.**
+In the app that is one switch. On the hosted page it is Yes and then the link in the email. Either way you receive `athlete.profile_sharing_changed`, carrying the athlete record with `profile_sharing: "on"`.
+
+*Your code:* call `GET /v1/athletes/{athlete_id}/fueling-profile` and store what it returns.
+
+**4. They change an answer in the app.**
+You receive `athlete.fueling_profile_updated`: the athlete record plus `changed_fields`, the names of the answers that changed, never their values.
+
+*Your code:* read the route again for the new values.
+
+**5. They stop sharing, or disconnect.**
+You receive `athlete.profile_sharing_changed` with `profile_sharing` at `off`, or at `not_linked` when the connection itself went. The route returns no answers from then on.
+
+*Your code:* stop treating the answers you hold as the athlete's current Saturday answers. How long you keep what you read is your platform's call under your privacy policy.
+
+## GET /v1/athletes/\{athlete_id\}/fueling-profile
+
+Returns the athlete's sharing state, and their Saturday app answers when it is `on`. Call it with your partner API key, or with an athlete-delegated OAuth token for that same athlete.
+
+
+
+```python Python
+import os
+import requests
+
+athlete_id = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"
+response = requests.get(
+ f"https://api.saturday.fit/v1/athletes/{athlete_id}/fueling-profile",
+ headers={"Authorization": f"Bearer {os.environ['SATURDAY_API_KEY']}"},
+)
+response.raise_for_status()
+body = response.json()
+
+if body["sharing"] == "on":
+ for field, answer in body["profile"].items():
+ print(f"{field}: {answer['value']} (calculations use {answer['calculations_use']})")
+else:
+ print(f"{body['sharing']}: {body['message']}")
+```
+
+```typescript TypeScript
+const athleteId = "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d";
+const response = await fetch(
+ `https://api.saturday.fit/v1/athletes/${athleteId}/fueling-profile`,
+ { headers: { Authorization: `Bearer ${process.env.SATURDAY_API_KEY}` } }
+);
+if (!response.ok) throw new Error(`Saturday returned ${response.status}`);
+const body = await response.json();
+
+if (body.sharing === "on") {
+ for (const [field, answer] of Object.entries(body.profile)) {
+ console.log(`${field}: ${JSON.stringify(answer.value)} (calculations use ${answer.calculations_use})`);
+ }
+} else {
+ console.log(`${body.sharing}: ${body.message}`);
+}
+```
+
+
+
+Every sharing state is a `200`. Switch on `sharing`, never on the text of `message`.
+
+### Sharing on
+
+```json
+{
+ "object": "fueling_profile",
+ "athlete_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
+ "sharing": "on",
+ "message": null,
+ "profile": {
+ "sex": { "value": "female", "calculations_use": "saturday_app" },
+ "age": { "value": 34, "calculations_use": "partner" },
+ "athlete_weight_kg": { "value": 61.2, "calculations_use": "saturday_app" },
+ "sweat_level": { "value": 7, "calculations_use": "saturday_app" },
+ "saltiness": { "value": 9, "calculations_use": "partner" },
+ "satiety_level": { "value": null, "calculations_use": "default" },
+ "fitness_level": { "value": 5, "calculations_use": "saturday_app" },
+ "carb_experience": { "value": "range_40_60", "calculations_use": "saturday_app" },
+ "usual_carb_consumption": { "value": "range_60_80", "calculations_use": "saturday_app" },
+ "concerns": {
+ "value": {
+ "performance": true,
+ "gut_distress": false,
+ "heat_tolerance": false,
+ "muscle_cramps": false,
+ "faintness": false,
+ "hunger": false,
+ "thirst": false,
+ "drinking_resistance": false
+ },
+ "calculations_use": "saturday_app"
+ }
+ },
+ "updated_at": 1760000000000
+}
+```
+
+### Sharing off
+
+```json
+{
+ "object": "fueling_profile",
+ "athlete_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
+ "sharing": "off",
+ "message": "This athlete has not chosen to share their Saturday app answers with you."
+}
+```
+
+### Not linked
+
+```json
+{
+ "object": "fueling_profile",
+ "athlete_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
+ "sharing": "not_linked",
+ "message": "This athlete has not connected a Saturday account, so there are no Saturday app answers to share."
+}
+```
+
+### Response fields
+
+| Field | Type | Description |
+|---|---|---|
+| `object` | string | Always `fueling_profile` |
+| `athlete_id` | string | The athlete you asked about |
+| `sharing` | string | `on`, `off`, or `not_linked` |
+| `message` | string or null | `null` when `sharing` is `on`; otherwise the reason there are no answers, the two sentences above |
+| `profile` | object | Only when `sharing` is `on`. One entry per shared answer, below |
+| `updated_at` | integer | Only when `sharing` is `on`. When the athlete's Saturday account record was last saved, Unix milliseconds. Any save moves it, including one that changes no shared answer, so use `athlete.fueling_profile_updated` to learn that answers changed |
+
+Each `profile` entry is an object with `value` and `calculations_use`. The names are the calculation-side names (`age` and `athlete_weight_kg`, as a calculate request takes them), and `concerns` holds the eight concern flags in one object:
+
+| Entry | `value` |
+|---|---|
+| `sex` | `male`, `female`, or `intersex`, as the athlete answered |
+| `age` | Whole years, the current year minus the athlete's birth year; `null` outside 10 to 100 |
+| `athlete_weight_kg` | Kilograms to one decimal; `null` outside 20 to 250 kg |
+| `sweat_level` | Integer 1 (light) to 9 (heavy) |
+| `saltiness` | Integer 1 (not salty) to 9 (very salty) |
+| `satiety_level` | Integer 1 (mostly whole food) to 9 (high-octane fuel) |
+| `fitness_level` | Integer 1 (beginner) to 9 (elite) |
+| `carb_experience` | A range token, as in `settings`: `range_0_30`, `range_40_60`, `range_gt_70` |
+| `usual_carb_consumption` | A range token, as in `settings`: `range_lt_60`, `range_60_80`, `range_80_100`, `range_gt_100` |
+| `concerns` | An object of eight booleans: `performance`, `gut_distress`, `heat_tolerance`, `muscle_cramps`, `faintness`, `hunger`, `thirst`, `drinking_resistance` |
+
+`value` is `null` when the athlete has not answered that question in the app, or holds an answer outside what the app writes. `concerns` is `null` when they never answered the concerns question; an object of all `false` means they answered "none of these".
+
+### calculations_use
+
+`value` is always the app answer. `calculations_use` says where the value Saturday's calculations use for this athlete comes from, field by field:
+
+| `calculations_use` | Meaning |
+|---|---|
+| `partner` | Your athlete record holds a value for this field, and calculations use yours. An inline value on a calculate call still wins for that call |
+| `saturday_app` | Your record has no value for this field, and calculations use the app answer |
+| `default` | Neither has a value. Calculations fall back to a default, the field shows up in `precision.missing_fields`, and the numbers come back as ranges |
+
+`partner` with a `value` that differs from what you store means the athlete answered differently in the two places. Calculations use yours; the app answer is there if you want to ask the athlete which is right.
+
+### Errors
+
+| Status | `type` and `code` | When |
+|---|---|---|
+| `401`, `403` | `authentication_error` | Missing, invalid, or revoked credentials. See [Authentication](/authentication) |
+| `403` | `authorization_error`, `athlete_scope` | An athlete-delegated token naming a different athlete |
+| `404` | `resource_not_found`, `athlete_not_found` | No athlete with this id under your partner account |
+| `429` | `rate_limit_error` | See [Rate Limiting](/rate-limiting) |
+| `500` | `api_error`, `storage_error` | Saturday could not read the athlete or their Saturday account; retry with backoff |
+
+Errors use the standard envelope in [Error Handling](/error-handling). An athlete who shares nothing is never an error: that is `200` with `sharing` set to `off` or `not_linked`.
+
+## profile_sharing on GET /v1/athletes/\{athlete_id\}
+
+Reading one athlete returns `profile_sharing` beside `subscription_status` (the record below is trimmed to the fields that matter here):
+
+```json
+{
+ "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
+ "external_id": "your-user-12345",
+ "subscription_status": "full",
+ "profile_sharing": "off",
+ "profile_complete": true
+}
+```
+
+The values are the same three as `sharing`. The field is computed on each read of one athlete. It is in the athlete record the two sharing webhooks carry, and not on `GET /v1/athletes` lists or in the record that `athlete.created`, `athlete.updated` and `athlete.profile_completed` carry. It is absent when Saturday could not read the state on that request; call the fueling-profile route for the answer.
+
+## Webhooks
+
+Two events cover sharing. Subscribe to them by name when you register or update a webhook; full payloads are in [Webhooks](/guides/webhooks#profile-sharing-event-payloads).
+
+- `athlete.profile_sharing_changed`: `data` is the athlete record, as `GET /v1/athletes/{athlete_id}` returns it, with `profile_sharing` showing the new state.
+- `athlete.fueling_profile_updated`: `data` is the same record plus `changed_fields`, the names of the shared answers that changed. Sent only while sharing stays on.
+
+Like every athlete record, `data` names the athlete in `id`, not `athlete_id`.
+
+A webhook is a cue to read the route, which is the source of truth. If one Saturday account is connected to more than one of your athlete records, each record gets an event.
+
+## MCP
+
+Partner MCP connections get a `get_fueling_profile` tool taking `athlete_id`, which returns this route's response. `get_athlete` carries `profile_sharing`, so an agent knows when to call it. See [MCP Integration](/guides/mcp-integration#tool-catalog).
diff --git a/guides/mcp-integration.mdx b/guides/mcp-integration.mdx
index 4f1d977..3f2417c 100644
--- a/guides/mcp-integration.mdx
+++ b/guides/mcp-integration.mdx
@@ -74,7 +74,8 @@ Every connection sees the tools below, minus those whose feature gate is closed
|------|--------------|------|
| `calculate_nutrition` | Fuel, hydration, and electrolyte targets for an activity described inline, or for a stored athlete via `athlete_id` | |
| `create_athlete` | Create an athlete under the partner account | |
-| `get_athlete` | Read an athlete's profile and fueling settings | |
+| `get_athlete` | Read an athlete's profile and fueling settings, with `profile_sharing` | |
+| `get_fueling_profile` | The answers an athlete gave in the Saturday app, while they share them with you; same response as `GET /v1/athletes/{athlete_id}/fueling-profile` | Partner connections |
| `list_athletes` | Page through the partner's athletes | |
| `update_athlete` | Partial update of an athlete; `settings` replaces the whole settings object | |
| `create_activity` | Record a training activity or race for an athlete | |
@@ -95,6 +96,8 @@ Every connection sees the tools below, minus those whose feature gate is closed
The gate column names the feature whose stage decides visibility for your partner account; `products`, `knowledge`, and `athlete_insights` are described on the [Feature Gates](/guides/feature-gates) page, and `bottling` is raised per partner. A tool whose feature is at ALPHA appears only for partners on that feature's alpha allowlist, the same rule its REST routes apply. A gated tool is absent from `tools/list` and a call to it returns the same "unknown tool" error as a name that never existed, so absence from `tools/list` is the answer, not an error to retry. Call `tools/list` rather than hardcoding the catalog.
+`get_fueling_profile` is listed on partner connections, on a partner key or a partner's athlete-delegated token, and has no feature gate. The Claude connector does not list it: there the caller is the athlete, who reads their account through `get_athlete`. Its result's `sharing` is `on`, `off`, or `not_linked`, and only `on` carries answers; see [Reading an athlete's fueling profile](/guides/fueling-profile).
+
On the Claude connector, `list_athletes` returns the signed-in athlete alone and `create_athlete` returns that same athlete rather than creating one. On any other athlete-delegated token both are rejected with `athlete_scope`. On every delegated connection an `athlete_id` argument naming a different athlete is rejected with `athlete_scope`.
diff --git a/guides/onboarding.mdx b/guides/onboarding.mdx
index c25c227..a8f54f5 100644
--- a/guides/onboarding.mdx
+++ b/guides/onboarding.mdx
@@ -58,6 +58,7 @@ An incomplete-profile response for a request with an `athlete_id` can carry `pre
- It asks only the questions that are missing, one per screen, mobile-first, in about two minutes.
- Each answer commits as it is given, so a half-finished session still narrows the bands.
- The finish screen asks the athlete's consent to show fuel for their most recent activity. If you have registered an `activity_link_template` on your partner account (`yourapp://activity/{external_id}`, for instance), it deep-links into your activity screen. Otherwise it renders the numbers itself and returns the athlete via your `return_url`.
+- When the athlete's Saturday account is connected to your platform and they are 18 or older, the finish screen also asks "Let \{your platform\} see your fueling answers?". Yes emails their Saturday address a one-tap confirmation, and sharing starts when they tap it; No turns sharing off at once. See [Reading an athlete's fueling profile](/guides/fueling-profile).
- The link stays valid for 90 days, and every later incomplete-profile response carries a fresh one, so athletes can come back and edit their answers.
### 2. Your UI, our questions (headless)
@@ -76,7 +77,9 @@ New questions arrive as recommended rather than required, so a schema change doe
### 3. Saturday app (once, forever)
-If the athlete uses the Saturday app with the same email address they have on your platform, their app onboarding powers their numbers on your platform too, resolved at calculation time rather than synced. Those answers are used for computation only and are never exposed through the API. An athlete can separately choose to share their profile values with you from app settings.
+If the athlete uses the Saturday app and their Saturday account is connected to your athlete record, their app onboarding powers their numbers on your platform too, resolved at calculation time rather than synced. The connection comes from a Saturday checkout link they open from your platform, or from an exact email match they confirm from an email Saturday sends to their Saturday address.
+
+Calculations use those answers whether or not the athlete shares them. You see the answers themselves only if the athlete chooses to share them with your platform: in the Saturday app, with the switch for your platform on the Partner Sharing card in Preferences (one switch per connected partner platform), or on the hosted page's finish screen, confirmed from an email. Nothing is shared until they turn it on, sharing has no expiry, and athletes under 18 cannot share. While they share, [`GET /v1/athletes/{athlete_id}/fueling-profile`](/guides/fueling-profile) returns their sex, age, weight and fueling answers.
### 4. Pass fields inline
diff --git a/guides/webhooks.mdx b/guides/webhooks.mdx
index f868954..5391861 100644
--- a/guides/webhooks.mdx
+++ b/guides/webhooks.mdx
@@ -74,6 +74,8 @@ These event types are emitted today:
| `athlete.created` | A new athlete is created |
| `athlete.updated` | An athlete profile is modified |
| `athlete.profile_completed` | An athlete's fueling profile crosses from incomplete to complete, which is the cue that this athlete is now eligible for exact prescriptions |
+| `athlete.profile_sharing_changed` | A linked athlete starts or stops sharing their Saturday app answers with you, or the link to their Saturday account goes while they shared. See [Reading an athlete's fueling profile](/guides/fueling-profile) |
+| `athlete.fueling_profile_updated` | An athlete who shares their Saturday app answers with you changes one of them in the app |
| `athlete.deleted` | An athlete is deleted through the API, or deletes their Saturday account |
| `activity.created` | A new activity is created |
| `activity.updated` | An activity is modified |
@@ -124,13 +126,74 @@ Any name outside this list is rejected with `400`, and one bad name fails the en
`reason` is the Stripe event type that ended access, such as `customer.subscription.deleted`, or one of `expired`, `refund`, `dispute`, `email_match_decayed` (an email-matched link that stopped matching), and `simulated` (test env only).
+### Profile sharing event payloads
+
+Like the other `athlete.*` events, both sharing events carry the athlete record, as `GET /v1/athletes/{athlete_id}` returns it when the event is sent. The athlete's id is `data.id`. Unlike `athlete.created` and `athlete.updated`, the record includes the two fields computed on read, `subscription_status` and `profile_sharing`.
+
+`athlete.profile_sharing_changed` says the athlete's sharing state changed; `profile_sharing` is the new state, `on`, `off`, or `not_linked`:
+
+```json
+{
+ "id": "evt_4f9a2c7e1b8d3a6f0c5e9b2d7a1f4c8e",
+ "type": "athlete.profile_sharing_changed",
+ "created_at": 1765467600000,
+ "data": {
+ "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
+ "partner_id": "your-partner-id",
+ "external_id": "your-user-12345",
+ "email": "alex@example.com",
+ "settings": { "sweat_level": 7, "saltiness": 5 },
+ "subscription_status": "full",
+ "profile_sharing": "on",
+ "profile_complete": false,
+ "created_at": 1765400000,
+ "updated_at": 1765400000
+ }
+}
+```
+
+It fires when:
+
+- the athlete turns sharing with you on or off, in the Saturday app or on the hosted onboarding page (`on` after they confirm the hosted page's email), or their account now reads as under 18 (`off`);
+- a Saturday account that shares with you is linked to the athlete (`on`);
+- the link goes, or moves to another Saturday account, while the athlete shared with you (`not_linked`, or the new account's `on` or `off`).
+
+A link that arrives while the athlete does not share with you sends nothing; `GET /v1/athletes/{athlete_id}` then reads `off`.
+
+`athlete.fueling_profile_updated` adds `changed_fields` to the record: the names of the shared answers that changed, never their values.
+
+```json
+{
+ "id": "evt_0b7e3d9a5c1f8e2a6d4b0c9f3e7a1d5b",
+ "type": "athlete.fueling_profile_updated",
+ "created_at": 1765467600000,
+ "data": {
+ "id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
+ "partner_id": "your-partner-id",
+ "external_id": "your-user-12345",
+ "email": "alex@example.com",
+ "settings": { "sweat_level": 7, "saltiness": 5 },
+ "subscription_status": "full",
+ "profile_sharing": "on",
+ "profile_complete": false,
+ "created_at": 1765400000,
+ "updated_at": 1765400000,
+ "changed_fields": ["sweat_level", "concerns"]
+ }
+}
+```
+
+It fires only while sharing stays on. `changed_fields` uses the entry names of the fueling-profile response (`sex`, `age`, `athlete_weight_kg`, `sweat_level`, `saltiness`, `satiety_level`, `fitness_level`, `carb_experience`, `usual_carb_consumption`, `concerns`), in that order. The record's `settings` are what you store, not the Saturday app answers; read those from `GET /v1/athletes/{athlete_id}/fueling-profile`. The save that turns sharing on sends only `athlete.profile_sharing_changed`, so read the profile then.
+
+Both events go to each athlete record linked to the Saturday account, one event per record. Treat them as a cue to read the fueling-profile route, the source of truth, rather than as an ordered log: the record is read when the event is sent, so a late redelivery shows the athlete as they are then. Their ids are `evt_` and 32 hex characters, the same on a redelivery, so a repeat can be dropped by `id`.
+
Don't cache tier from webhooks alone. `GET /v1/athletes/{id}` returns a computed `subscription_status` (`full` | `trial` | `teaser`) whenever you need ground truth, for example when an athlete returns from checkout.
## Webhook payload format
-Every delivery has this envelope. `id` is a UUID and `created_at` is a Unix timestamp in **milliseconds**. This one carries a full-tier prescription from `POST /v1/nutrition/calculate`:
+Every delivery has this envelope. `id` identifies the event and `created_at` is a Unix timestamp in **milliseconds**. This one carries a full-tier prescription from `POST /v1/nutrition/calculate`:
```json
{
@@ -162,7 +225,9 @@ What `data` carries depends on the event:
| Event | `data` |
|-------|--------|
-| `athlete.created`, `athlete.updated`, `athlete.profile_completed` | The athlete record, as `GET /v1/athletes/{athlete_id}` returns it |
+| `athlete.created`, `athlete.updated`, `athlete.profile_completed` | The athlete record, as `GET /v1/athletes/{athlete_id}` returns it, without the two fields computed on read, `subscription_status` and `profile_sharing` |
+| `athlete.profile_sharing_changed` | The athlete record, as `GET /v1/athletes/{athlete_id}` returns it, `subscription_status` and `profile_sharing` included |
+| `athlete.fueling_profile_updated` | The same record, plus `changed_fields` |
| `athlete.deleted` | `athlete_id` only |
| `activity.created`, `activity.updated` | The activity record |
| `activity.deleted` | `athlete_id` and `activity_id` only |
@@ -172,7 +237,7 @@ What `data` carries depends on the event:
A calculation with no athlete (a `POST /v1/nutrition/calculate` without `athlete_id`) carries neither id; `tier` tells you which tier the caller received.
-Athlete, activity, and event identifiers are all UUIDs; they carry no prefix, so do not pattern-match on one.
+Athlete and activity identifiers are UUIDs with no prefix. Treat an event `id` as an opaque string: most are UUIDs, an `athlete.deleted` sent because the athlete deleted their Saturday account is `athlete_deleted_` and the athlete id, and the two sharing events are `evt_` and 32 hex characters. Do not pattern-match on any of them.
## Verifying webhook signatures (HMAC-SHA256)
diff --git a/llms.txt b/llms.txt
index 2b78825..ae30143 100644
--- a/llms.txt
+++ b/llms.txt
@@ -21,6 +21,8 @@ Coaches use a separate surface with separate credentials: a `cp_live_` / `cp_tes
- POST /v1/nutrition/products/compare: compare products against a prescription
- POST /v1/athletes: create an athlete profile
- GET /v1/athletes: list the partner's athletes
+- GET /v1/athletes/{athlete_id}: one athlete, with computed subscription_status and profile_sharing (on | off | not_linked)
+- GET /v1/athletes/{athlete_id}/fueling-profile: the athlete's Saturday app answers (sex, age, weight, fueling answers), only while they share them with you
- POST /v1/athletes/{athlete_id}/activities: create an activity
- POST /v1/athletes/{athlete_id}/activities/{activity_id}/calculate: calculate that activity's prescription
- POST /v1/ai/conversations: start an AI-to-AI conversation with Saturday's assistant (SSE streaming)
@@ -47,6 +49,10 @@ Every response includes safety metadata (confidence_score, warnings, max_safe_fl
Subscribed athletes with complete profile and activity inputs get exact numbers. Incomplete inputs and free athletes get ranges. Check the range fields and precision object separately from tier. Safety data is complete either way.
+## Athlete Profile Sharing
+
+An athlete whose Saturday account is connected to your athlete record can share their Saturday app answers with your platform: one switch per partner platform in the Saturday app, or the hosted onboarding finish screen confirmed by email. Off until they turn it on, no expiry, never under 18. Saturday's calculations use the answers either way. Webhooks athlete.profile_sharing_changed and athlete.fueling_profile_updated say when to read again. Guide: https://docs.saturday.fit/guides/fueling-profile.
+
## Data Use
Every response carries an `X-Saturday-Data-License` header. Response data is licensed for display to end users only. Training or fine-tuning models on it, aggregating it, reverse engineering the calculations, and redistributing it are all prohibited. See https://docs.saturday.fit/guides/data-policy.
@@ -55,6 +61,7 @@ Every response carries an `X-Saturday-Data-License` header. Response data is lic
- Full docs: https://docs.saturday.fit
- Coach API guide: https://docs.saturday.fit/guides/coach-api
+- Reading an athlete's fueling profile (sharing states, fields, webhooks): https://docs.saturday.fit/guides/fueling-profile
- Claude connector, athlete and coach tools: https://docs.saturday.fit/guides/coach-connector
- Full LLM context: https://docs.saturday.fit/llms-full.txt
- MCP server: https://api.saturday.fit/mcp
diff --git a/scripts/test-response-examples.py b/scripts/test-response-examples.py
index f255177..1d5cbb7 100644
--- a/scripts/test-response-examples.py
+++ b/scripts/test-response-examples.py
@@ -121,3 +121,52 @@ def post(url, **kwargs):
)
print("Batch examples passed: Python and TypeScript, partial/all failures and nested athlete settings; no HTTP calls.")
+
+profile_page = (DOCS / "guides/fueling-profile.mdx").read_text()
+profile_python = re.search(r"```python[^\n]*\n(.*?)```", profile_page, re.S).group(1)
+profile_typescript = re.search(r"```typescript[^\n]*\n(.*?)```", profile_page, re.S).group(1)
+# The page's response examples are the fixtures, so the snippets run against what the page shows.
+profile_payloads = [json.loads(m) for m in re.findall(r"```json\n(\{\n \"object\": \"fueling_profile\".*?)```", profile_page, re.S)]
+assert [p["sharing"] for p in profile_payloads] == ["on", "off", "not_linked"], [p["sharing"] for p in profile_payloads]
+
+profile_runner = r'''
+import { readFileSync } from 'node:fs';
+import assert from 'node:assert/strict';
+const { code, payload, expected } = JSON.parse(readFileSync(0, 'utf8'));
+const shown = [];
+const fetch = async (url, init) => {
+ assert.match(url, /\/v1\/athletes\/[^/]+\/fueling-profile$/);
+ assert.equal(init.headers.Authorization, 'Bearer mock');
+ return { ok: true, status: 200, json: async () => payload };
+};
+const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
+await new AsyncFunction('fetch', 'console', 'process', code)(
+ fetch, { log: value => shown.push(value) }, { env: { SATURDAY_API_KEY: 'mock' } },
+);
+assert.deepEqual(shown, expected);
+'''
+
+for payload in profile_payloads:
+ if payload["sharing"] == "on":
+ expected_py = [f"{field}: {answer['value']} (calculations use {answer['calculations_use']})" for field, answer in payload["profile"].items()]
+ expected_ts = [f"{field}: {json.dumps(answer['value'], separators=(',', ':'))} (calculations use {answer['calculations_use']})" for field, answer in payload["profile"].items()]
+ else:
+ expected_py = expected_ts = [f"{payload['sharing']}: {payload['message']}"]
+ shown, urls = [], []
+
+ def get(url, **kwargs):
+ urls.append(url)
+ assert kwargs["headers"]["Authorization"] == "Bearer mock"
+ return SimpleNamespace(json=lambda: payload, raise_for_status=lambda: None)
+
+ with patch.dict(sys.modules, {"requests": SimpleNamespace(get=get)}), patch.dict(os.environ, {"SATURDAY_API_KEY": "mock"}):
+ exec(compile(profile_python, "fueling-profile.mdx", "exec"), {"print": shown.append})
+ assert shown == expected_py, (shown, expected_py)
+ assert urls[0].endswith("/fueling-profile"), urls
+ subprocess.run(
+ ["node", "--input-type=module", "-e", profile_runner],
+ input=json.dumps({"code": profile_typescript, "payload": payload, "expected": expected_ts}),
+ text=True, check=True,
+ )
+
+print("Fueling profile examples passed: Python and TypeScript, on/off/not_linked responses from the page; no HTTP calls.")