Skip to content

Cancel Subscription

POST
/api/billing/cancel
curl --request POST \
--url 'https://example.com/api/billing/cancel?organization_id=2489E9AD-2EE2-8E00-8EC9-32D5F69181C0' \
--header 'Authorization: Bearer <token>' \
--header 'Content-Type: application/json' \
--data '{ "at_period_end": true, "plan_code": "example", "product": "example" }'

Cancel a subscription for this organization.

Two addressing modes, because §13 gave the two subscription kinds different shapes:

  • product — a grandfathered à la carte subscription. Per-product, not per-org: an org can hold four independent ones (§12), so cancelling monitoring must not touch OTA. The row is found via the "plan_code:organization_id" composite in subscriptions.product_code, built from PLAN_CODE_FOR_PRODUCT.
  • plan_code — a tier, and ONLY a tier. A tier is one subscription covering all four products, so there is no per-product cancel to express and product cannot address it (its composite names the tier, not a product code). Cancelling it lands the org on the Free tier — all four products stay enabled at Free’s caps and overage_enabled is forced off (§13.3 lists cancellation among the events that land an org on Free).

plan_code is REFUSED (422 plan_code_not_a_tier) when it resolves to a non-tier subscription. Without that check a grandfathered owner sending {"plan_code": "base_ota"} would match their à la carte base_ota subscription’s composite, cancel that ONE Stripe subscription, and then have all four entitlements rewritten — while their monitoring / crash_reporting / remote_ssh subscriptions kept billing at Stripe against entitlements that no longer described them. Charging a customer while moving their entitlements out from under the subscriptions still being charged for is the worst outcome this route can produce, so the two addressing modes are kept strictly disjoint.

Exactly one of the two must be given; CancelSubscriptionRequest enforces that. This is also the path §13.2 requires a grandfathered org to walk before it can buy a tier — see checkout._checkout_tier’s docstring for why that supersession is explicit rather than automatic.

The gateway is called before any local write, so a refusal leaves no record of a cancellation that did not happen. The reverse gap — gateway cancelled, local commit lost — is self-healing: the gateway’s own customer.subscription.deleted webhook re-projects the same absolute state.

The immediate (at_period_end=False) Free landing is DURABLE, but only because entitlement_projection refuses to re-apply a terminating event to a subscription this endpoint had already recorded as terminal. Without that guard, the very Stripe call above fires customer.subscription. deleted, whose own absolute target is disabled — a legal degrade from enabled, so the never-upgrade clamp would not stop it — and the org would drop off Free seconds after this route answered 200. Anyone revisiting either side must keep the pair in step; the regression is pinned by test_the_cancel_webhook_echo_does_not_undo_the_free_landing.

TWO KNOWN RESIDUALS:

  • at_period_end=True on a tier ends at disabled, not Free. Entitlements are deliberately not moved here — the org keeps the tier it is still paying for until the period closes — and the move is then made by the gateway’s cancellation webhook, which arrives against a row that is still LIVE and therefore projects disabled normally. Closing that gap means letting a SUBSCRIPTION_CANCELED event raise an org from disabled to Free, which is exactly what entitlement_projection._target_entitlement_state’s clamp exists to prevent; it is left alone rather than weakened in passing.
  • A crash between this route’s two commits — main DB (entitlements) committed, critical DB (subscription status) lost — leaves the row non-terminal, so the webhook echo projects disabled over the Free landing. The commit order is deliberate (§3.8: under-serve, never over-serve) and the window is one commit wide.
organization_id
required
Organization Id
string format: uuid
Media type application/json
CancelSubscriptionRequest

POST /api/billing/cancel — address EITHER a product or a tier.

product is the à la carte product whose subscription to cancel (‘ota’ | ‘monitoring’ | ‘crash_reporting’ | ‘remote_ssh’ | ‘mcu_ota’) — NOT the subscriptions.product_code column, which is the composite “plan_code:organization_id” (§3.1). Naming it product keeps it in the same vocabulary as organization_entitlements.product and ProductEntitlementItem above; the route resolves the plan_code and the composite from it. mcu_ota is resolved as a special case (it has no entry in PLAN_CODE_FOR_PRODUCT — see _known_product below and app/api/billing.py’s cancel route), since it does not follow the four-product grandfathered-à-la-carte model PLAN_CODE_FOR_PRODUCT maps.

plan_code is the §13 addition, and it is additive in both senses: the field is new and optional, so every existing caller is unaffected. A tier subscription cannot be addressed by product — one subscription covers all four, and its composite names the tier — so cancelling a tier names the tier. Exactly one of the two must be given; supplying both would be two different cancellations in one request.

object
at_period_end
At Period End
boolean
default: true
plan_code
Any of:
string
>= 1 characters <= 64 characters
product
Any of:
string

Successful Response

Media type application/json
CancelSubscriptionResponse

POST /api/billing/cancel.

object
effective_at
Any of:
string format: date-time
status
required
Status
string
Example generated
{
"effective_at": "2026-04-15T12:00:00Z",
"status": "example"
}

Validation Error

Media type application/json
HTTPValidationError
object
detail
Detail
Array<object>
ValidationError
object
ctx
Context
object
input
Input
loc
required
Location
Array
msg
required
Message
string
type
required
Error Type
string
Example generated
{
"detail": [
{
"ctx": {},
"input": "example",
"loc": [
"example"
],
"msg": "example",
"type": "example"
}
]
}