Skip to content

Switch the active team for the current admin

POST
/api/me/active-team/
curl --request POST \
--url http://localhost:8000/api/me/active-team/ \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '{ "team_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9" }'

Flips the signed-in coach’s active_team to another team they belong to. Subsequent requests scope to the new team — clients, sessions, billing, everything tenant-aware. Returns the same shape as GET /api/me/ with the new active context. 404 if the team isn’t one the caller has membership on.

object
team_id
required

UUID of a team the caller has a membership on.

string format: uuid
Examples
ExampleSwitchActiveTeam

Switch active team

{
"team_id": "0a1b2c3d-4e5f-6071-8293-a4b5c6d7e8f9"
}
Media typeapplication/json
object
id
required

Admin UUID.

string format: uuid
firebase_uid
required

Firebase Auth user id. Useful for client-side identity mapping.

string
email
required

Admin email — same as the Firebase identity.

string
display_name
required

Admin display name. Set during onboarding via POST /api/me/onboarding/.

string
company_name
required

Active team’s name. Reported here as company_name for backward-compat with existing consumers; the field on Team itself is name.

string
role
required

Caller’s role on the active team — owner / member / platform_admin.

string
onboarded
required

True once POST /api/me/onboarding/ was completed. Gates the web shell — false → redirect to /onboarding. Equivalent to next_onboarding_step == 'done'; consumers should prefer next_onboarding_step for fine-grained routing.

boolean
email_verified
required

True once the admin entered a valid code from the verification email. Platform-admins are auto-stamped at creation. Gates onboarding.

boolean
session_quota
required

Sessions still available to the active team this billing period. Computed live from the quota ledger; never read from a counter that could drift.

integer
subscription
required

Active subscription snapshot for the active team, or null on free tier.

object
id
required

Subscription UUID. Stable across the row’s lifetime (status flips, plan upgrades reuse the same row when Stripe keeps the same sub_… id).

string format: uuid
status
required

Provider-mirrored lifecycle status. Treat as ‘live’ only when active or trialing — every other value triggers a paywall or banner. Values:

  • active — fully paid, quota usable.

  • trialing — Stripe trial in progress, same access as active.

  • past_due — payment failed at least once, Stripe Smart Retries in flight. The FE shows a yellow banner; new sessions 402 with detail="subscription_suspended".

  • unpaid — terminal failure after retries exhausted. The webhook handler will auto-fall-back to Free on the next terminal event.

  • canceled — user-initiated cancel that’s already settled (period elapsed). Auto-fallback creates a fresh Free Subscription for the team.

  • incomplete — Checkout abandoned before payment confirmation. Stripe garbage-collects after 24h.

  • trialing - Trialing

  • active - Active

  • past_due - Past due

  • canceled - Canceled

  • incomplete - Incomplete

  • unpaid - Unpaid

string
Allowed values: trialing active past_due canceled incomplete unpaid
plan
required

Frozen snapshot of the plan at subscription time. Even if the underlying Plan changes (sync_plans_from_stripe re-upserts on price/metadata edits), this Subscription row keeps the FK to the original Plan row.

object
id
required

Plan UUID. Internal identifier — stable across price + name changes within a tier. For Stripe-touching endpoints (POST /api/billing/checkout/, GET /api/billing/portal/update-plan/), pass stripe_price_id instead.

string format: uuid
stripe_price_id
required

Stripe price_… id that uniquely identifies this (tier × interval) row in Stripe. This is what POST /api/billing/checkout/ + GET /api/billing/portal/update-plan/?price=<id> consume — pass this value, not the Plan UUID. Free + Strategic carry synthetic non-Stripe ids (bundled_free_month / bundled_strategic_month) because they have no Stripe SKU; the FE should never POST those to the Stripe-touching endpoints (filter on role !== 'free' && role !== 'strategic' client-side, mirroring how the BE views 400 those targets).

string
<= 128 characters
name
required

Display name shown to the buyer (Free, Starter, Professional, Organization, Strategic).

string
<= 100 characters
interval
required

Billing cadence — month or year. Free + Strategic are monthly-only (Decision H + I); yearly variants exist only for Starter / Pro / Org.

  • month - Monthly
  • year - Yearly
string
Allowed values: month year
amount_cents
required

Price in minor units (cents/kopecks). Divide by 100 for the headline price. 0 for Free and Strategic — for Strategic the actual contract is sales-managed off-platform and display_mode=custom tells the FE not to render the price block.

integer
<= 2147483647
currency

ISO 4217 currency code (lowercase, e.g. usd). v1 ships USD-only; multi-currency is a v1.6 follow-up.

string
<= 3 characters
role
required

Canonical tier identifier: free / starter / professional / organization / strategic. Use name for display and role for tier-based feature gating server-side. Stable across price + name changes within a tier.

string
<= 50 characters
display_mode

How the FE should render this row. default shows price + quota + ceilings (the four paid tiers + Free). custom skips those fields and shows the sales-contact CTA (Strategic). Branching key for the pricing grid and SubscriptionPanel — see ADR-0019 §2 + §3.

  • default - Default
  • custom - Custom
string
Allowed values: default custom
session_quota

Sessions per billing period. Quota refreshes to exactly this number each renewal — leftover does NOT roll over (Decision 6). 0 for Strategic (engagement-specific, off-platform).

integer
<= 2147483647
max_seats

Team-membership ceiling. Inviting past this returns 402 with detail="seat_limit_reached". Free=1, Starter=1, Pro=3, Org=10, Strategic=0 (gate skipped for display_mode=custom).

integer
<= 2147483647
max_client_profiles

Client-row ceiling. Creating past this returns 402 with detail="client_limit_reached". Free=1, Starter=50, Pro=250, Org=1000, Strategic=0 (gate skipped for display_mode=custom).

integer
<= 2147483647
allowed_segments

Reserved for downstream pricing differentiation (segment-aware plans, ADR-0019 Decision J). Ships inert in v1.5PlanListView does NOT filter by team segment, so the field is informational only. Empty list = applies to all segments. v1.6 will activate the filter without an additional migration.

current_period_start

ISO-8601 UTC. Stamped on every renewal — the FE uses this to render ‘Renews every N days’ copy.

string format: date-time
nullable
current_period_end

ISO-8601 UTC when the current period (and the included quota) ends. On paid tiers this is when Stripe will next charge; on Free this is when the monthly renew_free_quota cron refreshes the 5-session balance.

string format: date-time
nullable
trial_end

ISO-8601 UTC when the Stripe trial flips to billed. Null when the subscription wasn’t created with a trial. Display a ‘Trial ends on …’ banner when set and status == "trialing".

string format: date-time
nullable
cancel_at_period_end

True when the buyer scheduled cancellation via the in-app Cancel button (Stripe modify with cancel_at_period_end=True). The buyer keeps access until current_period_end. Stripe Billing Portal-driven cancels may use the sibling cancel_at field instead — prefer the is_scheduled_to_cancel-equivalent FE predicate that ORs both.

boolean
cancel_at

ISO-8601 UTC of an explicit cancel timestamp set by Stripe (typically via Billing Portal’s ‘cancel on a date’ config, or schedule-driven cancels). When set, the FE should render the ‘ACCESS ENDS’ pill even if cancel_at_period_end is False. Null when no explicit timestamp is set.

string format: date-time
nullable
canceled_at

ISO-8601 UTC when Stripe last recorded a cancellation action against this subscription. Not the moment access ends — that’s current_period_end or cancel_at. Read for audit / display only.

string format: date-time
nullable
cancellation_reason

Stripe’s cancellation_details.reason enum value (cancellation_requested, payment_disputed, payment_failed, canceled_by_retention_policy) or empty string when absent. Surfaced for support / analytics — not used in any gate.

string
<= 64 characters
is_managed_externally

True for non-Stripe rows (Free signup default, Strategic sales-managed). When True: stripe_subscription_id + stripe_customer_id are null, the Stripe Billing Portal endpoint 404s, and the renewal clock is driven by our renew_free_quota cron (Free) or sales (Strategic). False for every Stripe-managed paid row.

boolean
is_scheduled_to_cancel
required

Unified predicate — True iff the sub has scheduled cancellation but still grants paid access. Mirrors the model @property at domain/models.py::Subscription.is_scheduled_to_cancel, which ORs the three Stripe-side cancel paths (cancel_at_period_end / cancel_at timestamp / schedule-driven canceled-while-period-active) and returns False once status flips to canceled / unpaid. The FE should consume this single field rather than re-deriving the OR locally — keeps the predicate in lockstep with BE gates (the in-app POST /api/billing/subscription/cancel/ + POST /api/billing/subscription/reactivate/ endpoints read it; plan-change goes through the Portal deep-link GET /api/billing/portal/update-plan/, where Stripe hides the upgrade button when the sub is scheduled to cancel).

boolean
managed_source

Where the row came from — the single source-of-truth kind. stripe (self-serve paid; the only value with is_managed_externally=False), free (registration default), strategic (sales-managed), legacy_migration (migration bridge), or admin_grant (complimentary tier granted from the admin). The FE branches on this: any non-stripe value has no Billing Portal and routes plan changes through Checkout, and admin_grant renders a comp badge with the real tier name rather than ‘Free’.

  • stripe - Stripe (self-serve paid)
  • free - Free (registration default)
  • strategic - Strategic (sales-managed)
  • legacy_migration - Legacy migration bridge
  • admin_grant - Admin-granted (comp / trial)
string
Allowed values: stripe free strategic legacy_migration admin_grant
teams
required

Every team this admin has a membership on, including their role and which one is currently active. Lets the shell render a switcher without a second round-trip.

Array<object>
object
id
required

Team UUID.

string format: uuid
name
required

Team display name.

string
role
required

Caller’s role on this team — owner or member.

string
is_active
required

True for the team that everything tenant-scoped currently resolves through (role, session_quota, subscription on Me reflect this team).

boolean
owner_email
required

Email of the team owner. Empty when not resolvable (rare — only during transient cleanup states).

string
segment
required

Active team’s market segment — one of sports, healthcare, wellness, or empty string. Set at onboarding step 1 by the team owner; reserved for future pricing differentiation. Empty for legacy teams or teams whose owner hasn’t completed step 1 yet — those owners get routed back via next_onboarding_step == 'segment'. Modelled as string (not a typed enum) on read so the empty case is one literal, not a SegmentEnum | BlankEnum union; write-side still validates against Segment.choices.

string
preferred_activities
required

Activities the active team trains, picked at onboarding step 2 by the team owner alongside segment. Both describe the team’s identity, so invitees inherit them and skip the step. Empty list = the team’s owner hasn’t completed step 2 yet → onboarding gate routes back via next_onboarding_step == 'activities'. Each element is an Activity value.

Array<string>
branding
required

Active team’s white-label branding for shared / exported Solution reports (logo, accent colour, footer text, brand name, contact email). Owner-edited via PATCH /api/me/branding/; the logo is uploaded via POST /api/me/branding/logo/.

object
brand_name
required

Display name on the report lockup, as stored. Empty when the team never set one — the report then self-attributes to the running product’s brand. The workspace name is never used as a fallback (#1979).

string
logo_url
required

Logo image URL (uploaded asset in the media bucket, or a CDN URL). Empty when unset.

string
accent_color
required

Hex accent colour (#rrggbb / #rgb) for report CTAs. Empty when unset.

string
footer_text
required

Short attribution shown in the report footer. Empty when unset.

string
contact_email
required

Optional contact email rendered as a footer mailto:. Empty when unset.

string
demo_completed_at
required

Timestamp of when the admin opened a demo session at onboarding step 5. Null = step not completed.

string format: date-time
nullable
next_onboarding_step
required
One of:
  • segment - Pick segment
  • activities - Pick activities
  • demo - View demo
  • account - Account + team
  • done - Done
string
Allowed values: segment activities demo account done
support_threads

True when server-persisted Kai threads (#945) are enabled on this environment. Gates the SPA’s threads drawer: false → the legacy stateless support chat. Environment-level (an env flag), not per-account — surfaced here so the SPA needs no extra round-trip and no rebuild when the flag flips.

boolean
notify_capture_check_email
required

Per-admin notification consent (#1868): whether the Capture Check email — “this session needs a re-record” — may be sent to this admin when a completed session they uploaded carries advisory capture findings. Defaults to true (a service message about their own upload); flipped in Settings → Notifications or via the email’s no-login unsubscribe link. Delivery is additionally gated by the deployment flag and a 24h per-uploader cadence, so true does not by itself mean emails flow.

boolean
blur_face_by_default
required

Per-admin privacy default (#370): the upload wizard’s per-session Face Blur toggle starts from this value (#1038), so when true every upload defaults to anonymising the subject’s face in the analysed (annotated) video. The coach can still flip blur for a single session without changing this default; API callers set blur_face per session on POST /api/sessions/. Off by default; set via PATCH /api/me/.

boolean
dismissed_hints
required

Ids of one-time UI hints (nudges) this admin has permanently dismissed via POST /api/me/hints/dismiss/ (#1038). The frontend gates a ‘don’t ask again’ nudge on membership here, so a dismissal holds across every device the coach signs in on. Free-form, frontend-owned ids (e.g. upload_blur_save_prompt).

Array<string>
Example
{
"subscription": {
"status": "trialing",
"plan": {
"interval": "month",
"display_mode": "default"
},
"managed_source": "stripe"
},
"next_onboarding_step": "segment",
"support_threads": false
}
Media typeapplication/json
object
detail
required

Human-readable message, or a stable machine code for the cases a client branches on. The standard envelope for 400 (validation — a field-keyed object may appear instead), 401 (missing / invalid credentials), 403 (authenticated but not permitted), and 404 (absent — cross-team records are collapsed to 404 so the API never leaks the existence of another team’s data).

string
Examplegenerated
{
"detail": "example"
}
Media typeapplication/json
object
detail
required

Human-readable message, or a stable machine code for the cases a client branches on. The standard envelope for 400 (validation — a field-keyed object may appear instead), 401 (missing / invalid credentials), 403 (authenticated but not permitted), and 404 (absent — cross-team records are collapsed to 404 so the API never leaks the existence of another team’s data).

string
Examplegenerated
{
"detail": "example"
}
Media typeapplication/json
object
detail
required

Human-readable message, or a stable machine code for the cases a client branches on. The standard envelope for 400 (validation — a field-keyed object may appear instead), 401 (missing / invalid credentials), 403 (authenticated but not permitted), and 404 (absent — cross-team records are collapsed to 404 so the API never leaks the existence of another team’s data).

string
Examples
Example404—NotATeamYouBelongTo

404 — Not a team you belong to

{
"detail": "team_not_found"
}