Current subscription for the active team
const url = 'http://localhost:8000/api/subscriptions/current/';const options = {method: 'GET', headers: {Authorization: 'Bearer <token>'}};
try { const response = await fetch(url, options); const data = await response.json(); console.log(data);} catch (error) { console.error(error);}curl --request GET \ --url http://localhost:8000/api/subscriptions/current/ \ --header 'Authorization: Bearer <token>'The most recently-created Subscription for the signed-in coach’s active team. Includes status, the embedded plan snapshot, the period dates, the cancel-at-period-end flag, and the is_managed_externally marker.
Post-ADR-0019 every team has a subscription — Free is auto-created at signup (is_managed_externally=true, plan.role="free"). A 200 with null body only happens on legacy un-migrated teams from before the early-adopter migration ran; treat that case as Free.
How to read the response
- Tier — branch on
plan.role(orplan.display_modewhen you want to lump Strategic with future custom tiers). - Live-ness — only
status ∈ {active, trialing}grants quota; anything else is read-only / paywalled. - Renewal copy —
current_period_endis the next refresh moment. On paid tiers it’s when Stripe charges; on Free it’s when the monthly cron refreshes to exactly 5 sessions. - Pending cancel —
cancel_at_period_end=truewhilestatus="active"means the buyer scheduled cancel via the Stripe Billing Portal. They keep access untilcurrent_period_end, then Stripe sendscustomer.subscription.deletedand the webhook handler auto-falls-back to Free. - Dunning —
status="past_due"means Stripe Smart Retries is in flight. Render a yellow banner pointing at/api/billing/portal/so the buyer can update their payment method.
The plan field is a frozen snapshot at subscription time (FK to a Plan row). sync_plans_from_stripe re-upserts on price / metadata edits — existing subscriptions stay on the old Plan row until the buyer switches tiers.
Authorizations
Section titled “Authorizations”Responses
Section titled “Responses”object
Subscription UUID. Stable across the row’s lifetime (status flips, plan upgrades reuse the same row when Stripe keeps the same sub_… id).
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 asactive. -
past_due— payment failed at least once, Stripe Smart Retries in flight. The FE shows a yellow banner; new sessions 402 withdetail="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
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
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.
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).
Display name shown to the buyer (Free, Starter, Professional, Organization, Strategic).
Billing cadence — month or year. Free + Strategic are monthly-only (Decision H + I); yearly variants exist only for Starter / Pro / Org.
month- Monthlyyear- Yearly
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.
ISO 4217 currency code (lowercase, e.g. usd). v1 ships USD-only; multi-currency is a v1.6 follow-up.
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.
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- Defaultcustom- Custom
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).
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).
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).
Reserved for downstream pricing differentiation (segment-aware plans, ADR-0019 Decision J). Ships inert in v1.5 — PlanListView 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.
ISO-8601 UTC. Stamped on every renewal — the FE uses this to render ‘Renews every N days’ copy.
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.
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".
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.
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.
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.
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.
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.
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).
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 bridgeadmin_grant- Admin-granted (comp / trial)
Examples
Free (registration default)
{ "id": "9b0e1c2d-3a4b-5c6d-7e8f-90a1b2c3d4e5", "status": "active", "plan": { "id": "11111111-1111-1111-1111-111111111111", "stripe_price_id": "bundled_free_month", "name": "Free", "interval": "month", "amount_cents": 0, "currency": "usd", "role": "free", "display_mode": "default", "session_quota": 5, "max_seats": 1, "max_client_profiles": 1, "allowed_segments": [] }, "current_period_start": "2026-05-01T00:00:00Z", "current_period_end": "2026-06-01T00:00:00Z", "trial_end": null, "cancel_at_period_end": false, "is_managed_externally": true}Professional, active
{ "id": "f12a3b4c-5d6e-7890-abcd-ef0123456789", "status": "active", "plan": { "id": "33333333-3333-3333-3333-333333333333", "stripe_price_id": "price_1TX5kL3IwNiuriuFabcdefgh", "name": "Professional", "interval": "month", "amount_cents": 9900, "currency": "usd", "role": "professional", "display_mode": "default", "session_quota": 75, "max_seats": 3, "max_client_profiles": 250, "allowed_segments": [] }, "current_period_start": "2026-05-13T00:00:00Z", "current_period_end": "2026-06-13T00:00:00Z", "trial_end": null, "cancel_at_period_end": false, "is_managed_externally": false}Pro, scheduled to cancel at period end
{ "id": "f12a3b4c-5d6e-7890-abcd-ef0123456789", "status": "active", "plan": { "id": "33333333-3333-3333-3333-333333333333", "stripe_price_id": "price_1TX5kL3IwNiuriuFabcdefgh", "name": "Professional", "interval": "month", "amount_cents": 9900, "currency": "usd", "role": "professional", "display_mode": "default", "session_quota": 75, "max_seats": 3, "max_client_profiles": 250, "allowed_segments": [] }, "current_period_start": "2026-05-13T00:00:00Z", "current_period_end": "2026-06-13T00:00:00Z", "trial_end": null, "cancel_at_period_end": true, "is_managed_externally": false}Pro, past_due (Stripe dunning in flight)
{ "id": "f12a3b4c-5d6e-7890-abcd-ef0123456789", "status": "past_due", "plan": { "id": "33333333-3333-3333-3333-333333333333", "stripe_price_id": "price_1TX5kL3IwNiuriuFabcdefgh", "name": "Professional", "interval": "month", "amount_cents": 9900, "currency": "usd", "role": "professional", "display_mode": "default", "session_quota": 75, "max_seats": 3, "max_client_profiles": 250, "allowed_segments": [] }, "current_period_start": "2026-05-13T00:00:00Z", "current_period_end": "2026-06-13T00:00:00Z", "trial_end": null, "cancel_at_period_end": false, "is_managed_externally": false}Strategic (sales-managed)
{ "id": "abc12345-6789-0123-4567-890abcdef012", "status": "active", "plan": { "id": "55555555-5555-5555-5555-555555555555", "stripe_price_id": "bundled_strategic_month", "name": "Strategic", "interval": "month", "amount_cents": 0, "currency": "usd", "role": "strategic", "display_mode": "custom", "session_quota": 0, "max_seats": 0, "max_client_profiles": 0, "allowed_segments": [] }, "current_period_start": "2026-05-01T00:00:00Z", "current_period_end": "2026-06-01T00:00:00Z", "trial_end": null, "cancel_at_period_end": false, "is_managed_externally": true}object
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).
Examplegenerated
{ "detail": "example"}object
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).
Examplegenerated
{ "detail": "example"}