---
title: "Cancel subscription"
method: POST
path: "/api/billing/cancel"
tags: ["billing", "internal"]
---

# Cancel subscription

`POST /api/billing/cancel`

In-app cancel modal endpoint. Sets Stripe `cancel_at` to the current
period end (via Stripe's `MaxPeriodEnd` sentinel), stashes the canonical
Scanopy reason in subscription metadata, returns the period end so the
modal can render the retention disclosure.

## Request body

- CancelSubscriptionRequest
  - `comment` string, nullable
  - `reason_code` 'too_expensive' | 'missing_features' | 'switched_service' | 'unused' | 'customer_service' | 'low_quality' | 'too_complex' | 'other', required — Cancellation reason captured in `SubscriptionCancelled` / `CancellationInitiated` events. Mirrors the values surfaced in the in-app cancel flow (Phase 5).
  - `save_offer_redeemed` 'pause' | 'discount' | 'downgrade' — Save-offer choices presented during in-app cancellation (Phase 5).
  - `save_offer_shown` SaveOffer[]

## Response `200`

Cancellation initiated

- ApiResponseCancelSubscriptionResponse
  - `data` object
    - `period_end` string, date-time, required
  - `error` string, nullable
  - `meta` ApiMeta, required — API metadata included in all responses
    - `api_version` integer, required — API version (integer, increments on breaking changes)
    - `server_version` string, required — Server version (semver)
  - `success` boolean, required

## Other responses

- `400` — No active subscription or billing not enabled

## Changes

- **2026-05-01** `71cdaaff2425` — 1 info
  - endpoint added
- **2026-04-20** `f04499e2280f` — 1 breaking
  - api path removed without deprecation

[Change history](https://skmtc.dev/scanopy/apis/scanopy-api/changes/api/billing/cancel/post.md)

---

[API](https://skmtc.dev/scanopy/apis/scanopy-api.md) · [All operations](https://skmtc.dev/scanopy/apis/scanopy-api/llms.txt) · [OpenAPI document](https://skmtc-service-production.skmtc.workers.dev/v1/apis/scanopy/scanopy-api/revisions/bd76f90aa44b/schema)
