Skip to content

Current subscription for the active team

GET
/api/subscriptions/current/
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 (or plan.display_mode when you want to lump Strategic with future custom tiers).
  • Live-ness — only status ∈ {active, trialing} grants quota; anything else is read-only / paywalled.
  • Renewal copycurrent_period_end is 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 cancelcancel_at_period_end=true while status="active" means the buyer scheduled cancel via the Stripe Billing Portal. They keep access until current_period_end, then Stripe sends customer.subscription.deleted and the webhook handler auto-falls-back to Free.
  • Dunningstatus="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.

Media typeapplication/json
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
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
}
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"
}