Skip to content

Cancel the team's paid plan (at period end by default)

POST
/api/billing/subscription/cancel/
curl --request POST \
--url http://localhost:8000/api/billing/subscription/cancel/ \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '{}'

Owner-only. Schedules cancellation at period end (Portal-consistent default) or, with immediately=true and platform-admin privilege, cancels right now.

Default (cancel_at_period_end=true)

Stripe flips the sub’s cancel_at_period_end to true and fires customer.subscription.updated. The webhook reflects this locally; the FE shows the pending-cancel banner. At current_period_end, Stripe sends customer.subscription.deleted and _fall_back_to_free mints a Free row with 5 sessions for the team.

Immediate (platform-admin only)

Cancels the Stripe sub now. The fallback fires on the next webhook delivery (customer.subscription.deleted). Used for ops cleanup (test teams, fraud reports). Regular owners sending immediately=true get 400 — protects them from accidentally losing mid-period access on a misclick.

Idempotent

Calling Cancel on a sub that’s already scheduled returns 200 with already_scheduled — the FE can ignore the second click without an error toast.

object
immediately

When False (default): schedule cancel-at-period-end — the team keeps access through the period they paid for, matches the Stripe Billing Portal config + ADR-0019 §8. When True: cancel the Stripe sub right now. Platform-admin only — regular owners that send true get 400. Owners losing access mid-period is rarely intended; the at-period-end default protects them from a misclick.

boolean
Examples

Schedule cancel at period end (default)

{}
Media typeapplication/json
object
status
required

Idempotent reply when the sub is already set to cancel at period end. Clicking Cancel twice is safe.

  • already_scheduled - Cancel was already scheduled
string
Allowed values: already_scheduled
Examples
ExampleAlreadyScheduled(idempotentSecondClick)

Already scheduled (idempotent second click)

{
"status": "already_scheduled"
}
Media typeapplication/json
object
status
required

scheduled — Stripe accepted the modify call; FE shows the canceled-state banner after the webhook lands. canceled — immediate-cancel path (platform-admin only). In both cases the local row is NOT mutated here; the webhook is authoritative.

  • scheduled - Cancel scheduled at period end
  • canceled - Cancel fired immediately (platform admin)
string
Allowed values: scheduled canceled
Examples
{
"status": "scheduled"
}

immediately_requires_platform_admin (non-admin sent immediately=true), or stripe_cancel_failed.

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"
}

Caller is not the team owner. Also returned to a partner aik_… API key: billing writes require the Firebase scheme (billing is read-only for keys).

no_paid_subscription — Free has no in-app cancel (delete the team instead); Strategic is sales-managed off-platform.