Skip to main content

Coach API

The Coach API exposes a coach’s Saturday surface over a coach-scoped REST API, and over an MCP connector: the per-athlete fueling rollup, AI reports, the roster’s needs-attention markers, the notification and alerting settings, and a read-only view of the coach’s billing. It is built for a coach who wants to automate fueling monitoring: read who needs attention, pull a report, and configure alert rules and digests across a whole roster programmatically.
Tier requirements. Coach endpoints require the Pro Coach tier or above (Pro Coach, Head Coach, Business, Enterprise), and the tier is checked on every request. Two further gates sit above that line: minting a coach API key in the portal requires the Business or Enterprise tier, and webhook delivery requires Business or Enterprise. A Pro Coach or Head Coach therefore reaches the whole surface through the OAuth connector, and reaches it through an API key only if someone on a Business or Enterprise plan minted one.
When a tier lapses. On an OAuth token, the coach tools stop resolving and coach endpoints return 404, while the token keeps working for the coach’s athlete data. There is no error and no separate “downgrade” call. A coach API key has no athlete-self facet to fall back to, so the request is refused with 403 coach_tier_required.

Authentication

Two ways to authenticate as a coach. Both resolve to the same coach identity and roster. Coach API keys are minted in the coach portal under Admin → API Keys. Pass the key as a Bearer token:
OAuth2 coaches use the connector OAuth flow with coach scopes (coach:roster, coach:reports, coach:alerts, coach:webhooks). Because a coach is also an athlete, the same OAuth token acts as an athlete-self token on /v1/athletes/*.

Scopes and capabilities

A coach token or key carries scopes, and each scope confers one or more of five capabilities. A route is reachable only when the credential confers the capability it requires; otherwise it returns the uniform 404. Reading config takes the same capability as writing it: a credential without it gets 404 from GET /v1/coach/config/notification-rules as well as from PUT. An API key’s scopes, chosen when the key is minted in the portal, map onto the same capabilities. Billing is reachable with an API key only; the OAuth connector has no billing scope. Scopes union: a key with several scopes holds every capability any of them confers, and nothing more. roster:write also reads the roster, since configuring alerts against a roster means seeing it. roster:read alone does not open the report routes; add reports:read, or pick org:read or higher. Billing is never bundled: only a key that carries billing:read reads billing, whatever else it carries, so a key built for roster automation cannot see money unless the coach ticked that scope. The portal offers each coach only the scopes their role and plan can mint. webhooks:manage needs a coaching role and the Pro Coach plan or higher; org:admin needs an organization owner or admin role; billing:read needs the role that sees the portal’s Billing pages. A solo coach on Business mints everything except org:admin.
Roster confinement. Every {athlete_uid} you pass is checked against your roster. An athlete who isn’t on your roster, and an athlete who doesn’t exist, both return 404 resource_not_found. The two cases are indistinguishable, so the API never reveals whether an athlete exists.

Reads

List the roster

GET /v1/coach/roster returns every athlete you coach, each with a needs-attention summary over the look-back window.
The markers come from Saturday’s single shared concern definition, so they match the coach portal table and the digest exactly. An athlete who fueled well shows flagged: false.

Roster digest (flagged-only)

GET /v1/coach/roster/digest returns the same data for athletes who crossed a concern bar this window, most-flagged first. Athletes who did not cross a bar are omitted, which is what makes this the useful call on a large roster.
flagged carries the same entry shape as GET /v1/coach/roster above, ordered most-flagged first. flagged_count counts the entries in it; total_count counts the whole roster.

Per-athlete fueling rollup

GET /v1/coach/athletes/{athlete_uid}/fueling-rollup returns one athlete’s in-window sessions (the same table the portal renders) plus the concern summary and the resolved cutoffs that produced the markers.
The session object above is abbreviated. Each entry also carries the as-used and suggested prescription totals, prep fidelity, leftover reuse, report source and completeness, intensity, the profile snapshot, opaque vessel_reports and weather maps, and the derived adherence_vs_suggested, dial_down_gap, and per_hour triples.

Data sources

A session recorded on a connected device names where it came from. The AI report and AI plan responses carry source_attributions too, for the sessions the narrative was written from (absent or empty when none requires one). Show them with the narrative. Sessions from some connected services stay on Saturday and are never returned by the Coach API, so no response carries them or anything built from them. A workout recorded by Garmin or COROS alone is withheld; when the same workout also reached Saturday from another service, it comes back with only that other service’s details. When an athlete has connected such a service, the fueling rollup, AI report and AI plan responses, and that athlete’s roster row, carry a withheld object (kind: connected_app_sessions, plus a message to show). Treat the sessions you do get as incomplete: a short list does not mean the athlete trained less. The adherence fractions are uncapped, so a value above 1.0 is genuine over-consumption rather than an error. Every missing value stays null, never 0, and an absent symptoms key is not a zero.

AI report

GET /v1/coach/athletes/{athlete_uid}/report returns the AI-generated fueling report: a third-person narrative grounded only in the athlete’s numbers, plus the structured concern summary behind it, so an agent can quote the prose or compute on the data.
Served from cache unless a newer session has landed or refresh=true.

Session detail

GET /v1/coach/athletes/{athlete_uid}/sessions/{activity_id} drills into one session: the full per-session projection (planned-vs-actual fueling, symptoms, vessel reports, weather, sleep) plus the concern markers that session crossed.
session is the same projection as a rollup entry, abbreviated here. An activity outside the athlete’s history returns 404.

Configuration

Most of the Coach API is a configuration surface. It writes the same alert-rule and report-settings model the portal reads: channels, triggers, per-trigger thresholds, the overall/group/athlete scope hierarchy, cadence, quiet hours, the hourly cap, two-trigger combinators, and AI-report defaults. The portal UI and the API are two views of one config model.
Scope precedence. Config applies at one of three scopes: overall (the whole roster), group (a coach group), or athlete (one athlete). When resolving what an athlete sees, the most specific scope wins (athlete over group over overall). scope_id is required for group and athlete, and omitted for overall.

Notification rules

GET /v1/coach/config/notification-rules?scope=overall reads the rules set at exactly that scope, not the merged resolution. A scope with no rules returns an empty rule set. PUT /v1/coach/config/notification-rules replaces the rule set at a scope and answers {"ok": true, "scope": "..."}. It is an idempotent upsert: re-running with the same body is a no-op, so no per-request idempotency key is needed. A malformed rule set (an unknown trigger, channel, or cadence, a bad combinator, quiet hours enabled without valid HH:MM times, or an hourly cap outside 1 to 12) returns 400 invalid_config. Every field you leave out is cleared at that scope, hourly_cap included, so the broader scope’s value applies.
Triggers: under_fuel, symptom, low_rating, hyponatremia_pattern, dial_down, sleep_trend, went_quiet. Channels: in_portal, email, push, webhook. Cadence: realtime (sent as it happens) or digest (bundled into one daily item per athlete). Thresholds are fractions of the as-used prescription. urgent_threshold sets the urgent band (0.6 means below 60%). amber_threshold is optional and decouples this trigger’s marker line from the shared cutoff; omit it to use the resolved concern cutoff. Hourly cap limits the emails, and separately the pushes, that one athlete’s alerts send you in any 60 minutes, urgent alerts included. per_hour is a whole number from 1 to 12; with no scope setting hourly_cap, it is on at 1 per hour. Alerts over the limit wait and go out together in one email (or one push) when the hour frees, so none is dropped. "enabled": false removes the limit; quiet hours and each rule’s cadence still apply. In-portal alerts and webhook events are never limited. When an athlete is in two groups that set different caps, the looser one applies: off, or else the higher per_hour. The GET response carries hourly_cap only where that scope sets it. Combinators are bounded two-trigger ANDs: both legs must be markers on the same session (“sodium short AND a cramp”). Exactly two distinct session-level triggers (under_fuel, symptom, low_rating, hyponatremia_pattern, dial_down), with no OR, NOT, or nesting. sleep_trend and went_quiet are window-level and cannot be combined.

Presets

POST /v1/coach/config/preset applies a named starting point at a scope, which you can then tweak rule by rule. It leaves the scope’s hourly_cap as it was. It answers {"ok": true, "preset": "..."}; an unknown preset returns 400 invalid_config.

AI-report & concern settings

GET /v1/coach/config/report-settings?scope=overall reads the report window/focus defaults and any overridden concern cutoffs set at exactly that scope. PUT /v1/coach/config/report-settings upserts them. Unset fields fall through to the broader scope.
Athlete data is read-only. The Coach API never writes an athlete’s fueling data, debriefs, or prescriptions. A coach configures only the coach’s alerts, report settings, and thresholds.

Webhooks

A webhook is another delivery channel on the alerts you configure ("channels": ["webhook"]). Register an endpoint, then select webhook as a channel on any rule.
Webhook delivery requires the Business or Enterprise tier. Registration succeeds at Pro Coach and Head Coach, but nothing is delivered until the tier qualifies. The tier is checked on every delivery, so delivery stops as soon as it drops.
POST /v1/coach/webhooks registers an endpoint and answers 201 with the signing secret, once.
secret appears in this response and never again. GET /v1/coach/webhooks omits it, and there is no endpoint that re-reads it. Store it when you register, or delete the endpoint and register a new one to get a fresh secret.
Event types: concern.detected and athlete.needs_attention, which an empty events list subscribes you to (["*"] subscribes to every coach event), plus coach.message.sent, which is opt-in and fires only for Enterprise coaches, mirroring messages the coach sends from the portal. athlete.deleted (data: athlete_uid only) fires when an athlete you coached deletes their Saturday account; it goes to every endpoint subscribed to any of these events, so a system holding what they said about the athlete can delete it, and carries one X-Saturday-Event-ID per athlete. An unknown event name fails registration with 400. The URL must be public https on the standard port; internal, metadata, and loopback URLs are rejected at registration. Each delivery is a POST carrying the event name in X-Saturday-Event-Type and an HMAC-SHA256 signature in X-Saturday-Signature (t=<unix seconds>,v1=<hex>) computed with the secret returned at registration. See Webhooks for verifying it. The body is the event, with data shaped per event type:
Each event is delivered once. A non-2xx response or a connection failure is recorded and counts toward the endpoint’s fail_count; a 2xx resets it, and the endpoint is disabled automatically after 15 consecutive failures. Re-enabling it clears the count.

Billing (read-only)

A key carrying billing:read reads the figures the portal’s Billing pages show, for the coach who minted the key: their seats, their financial ledger, their platform subscription, and their Stripe Connect earnings. Every amount is an integer number of cents in the row’s currency (usd), and every timestamp is Unix milliseconds, the same values the portal renders. There is no billing write scope: covering athletes, changing plans, refunds and payouts stay in the portal. A coach with no organization reads their figures on every endpoint. A coach with no Stripe Connect account gets a null account and zero totals from the Connect endpoints, never an error.

Seat state

GET /v1/coach/billing/seat-state returns the live seat picture from the roster header: the funding tier, the included allotment and how much of it is used, the coach-paid count, coach-paid athletes whose seat starts billing later (pending_coach_paid_count: their own paid Monthly time has not run out yet), and what covering one more athlete costs now. Pass ?org_id= to read the organization’s seats as payer; only the coach’s organization is accepted, and any other value answers 404.

Ledger

GET /v1/coach/billing/ledger returns one page of the coach’s financial ledger, newest first: coaching fees received, platform and Stripe fee deductions, subscription charges and refunds.
next_cursor is present only when another page exists; pass it back as cursor. An empty ledger is {"entries": []}. A view, limit or cursor outside its domain answers 400 invalid_value.

Tier status

GET /v1/coach/billing/tier-status returns the coach’s active platform tier subscriptions and whether any source currently grants access.

Connect summary

GET /v1/coach/billing/connect/summary returns the Stripe Connect account status plus this month’s and lifetime totals.
Without a Connect account, connect_account is null, is_onboarded is false and every total is 0. The fee terms come with every summary: Saturday’s fee is platform_fee_bps of a charge and at least platform_fee_minimum_cents. From coach_pays_stripe_fees_from (Unix milliseconds; 0 while Saturday pays it) the coach pays Stripe’s processing fee, at the rates on Saturday’s account: stripe_fee_domestic_bps or stripe_fee_international_bps of the charge plus stripe_fee_fixed_cents.

Connect earnings

GET /v1/coach/billing/connect/earnings returns the gross, Stripe fee, platform fee and net totals across settled charges, plus the twenty most recent per-charge breakdowns.
breakdowns is an empty list when there are no charges. In each breakdown, stripe_fees_cents is the part of Stripe’s processing fee the coach pays and stripe_fee_paid_by (coach or saturday, absent when no fee was recorded) says who paid it. refunded_cents went back to the athlete and platform_fee_returned_cents is Saturday’s fee given back on those refunds. disputes_cents is what disputes and their fees took, less what was paid back; dispute_costs_recovered_cents is the part of it that repaid earlier disputes’ costs. net_to_coach_cents is the gross less the Stripe fee the coach paid, Saturday’s fee, refunds and disputes, plus the fee given back. The summary’s total_ fields add these up across the coach’s charges.

Connect transactions

GET /v1/coach/billing/connect/transactions returns one page of Connect charges, newest first, with the fee decomposition of each. limit and cursor work as on the ledger; total counts the charges in this page.
A charge reads refunded once any part of it is refunded; refund_amount_cents says how much went back to the athlete, so the rest can still be refunded. stripe_fees_cents is the part of Stripe’s processing fee the coach pays, and stripe_fee_paid_by (coach or saturday) says who paid it on a destination charge; it is absent on an older direct charge, where the coach paid it. platform_fee_returned_cents is Saturday’s fee given back on refunds. arrangement_id, refund_amount_cents, stripe_fee_paid_by and platform_fee_returned_cents are left out while they are empty or zero.

Connect arrangements

GET /v1/coach/billing/connect/arrangements returns every coach-to-athlete billing arrangement the coach has configured, in any status.

Athlete identity

Coach API responses identify each athlete by Saturday UID, the same identifier the portal uses, and carry their fueling data exactly as the coach sees it there: sessions, symptoms, ratings, sleep, and the calculator profile snapshot. The coach owns the coaching relationship, so the API applies no de-identification; identity levels belong to the portal’s export feature. Handling that personal data downstream is your responsibility. See the data policy.

Rate limits

Coach requests draw from the coach’s bucket: 50 requests per second with a burst of 500, the same whether the call is a REST request to /v1/coach/* on either credential or a coach tool used in chat through the connector. Coach tools in chat also pass the connector’s shared bucket, as every connector call does. The billing reads share the coach bucket with the rest of the coach surface. Athlete tools used in the same chat, and a coach token’s athlete-self calls on /v1/athletes/*, keep the per-athlete bucket that applies to every athlete-delegated caller. A roster pull or digest is one request whatever the roster size. Every response carries the rate-limit headers and a 429 carries Retry-After; see rate limiting. A coach API key reaches /v1/coach/* only; on any other endpoint it is refused with 403 insufficient_scope.

SDKs

Both the TypeScript and Python SDKs expose the coach surface as a coach resource: