Blocked subscription shapes
Reference table. The comprehensive list of subscription shapes that block automated cancel and route to a manual cancellation request.
For per-offer eligibility (which dimensions block which retention offer), see per-offer eligibility. This page is the single answer to “why isn’t this subscription getting automated cancel?”
Shapes that route to manual cancellation request
| Shape | What we read | Why it’s blocked | Stripe docs |
|---|---|---|---|
| Multi-item or mixed-interval subscription | subscription.items.data.length > 1 or items.has_more === true | Per-item billing periods break a single-date cancellation copy. Mixed-interval items have separate final-invoice semantics. Paginated single-page subs (length === 1, has_more === true) are semantically multi-item and the cancel resolver fails closed | pagination , subscriptions overview |
| Schedule-attached subscription | subscription.schedule !== null | A direct cancel_at_period_end may be overwritten by the next phase boundary or conflict with the schedule’s end_behavior. Stripe’s recommended path is subscriptionSchedules.release first, then cancel — on the roadmap | subscription-schedules |
| Cadence-attached subscription | subscription.cadence !== null (Stripe v2 preview) | Cadence is a new Stripe v2-preview billing primitive. Cancel behavior on cadence-attached subs is undocumented. Pause docs explicitly forbid first-class pause on cadence-attached subs, so we fail closed across all five offers | billing-cadences |
| Pause collection set by another tool | subscription.pause_collection !== null AND not self-owned | Cancel semantics on a paused-collection sub set by another tool are outside the launch contract. Self-owned pauses use the resume path | pause-payment |
| First-class paused status | subscription.status === 'paused' | Stripe’s resume path may create a resumption invoice and leave the sub paused if payment is not handled. Cancel semantics on a true first-class paused sub are not part of the launch contract | pause-payment |
| Pending update queued | subscription.pending_update !== null | Out-of-band pending updates (typically from payment_behavior='pending_if_incomplete') interleave unsafely with retention mutations. Per-mutation interaction is unverifiable in Stripe docs | subscriptions overview |
| Past-due subscription | subscription.status === 'past_due' | Open invoices, dunning settings, and period boundaries make “scheduled for period end” ambiguous. We do not mutate dunning state automatically | subscriptions overview |
| Unpaid subscription | subscription.status === 'unpaid' | Same dunning-state concern as past-due, plus access has typically already been revoked by the merchant’s collection settings | subscriptions overview |
| Incomplete subscription (Checkout origin) | subscription.status === 'incomplete' from a Checkout Session | Stripe’s docs explicitly forbid subscriptions.update on an incomplete Checkout-origin sub: “you can’t update the subscription or its invoice if the session’s subscription is incomplete” | subscriptions overview |
| Incomplete subscription (non-Checkout origin) | subscription.status === 'incomplete' from a non-Checkout origin | Public Stripe docs do not provide enough webhook or merchant-callback guarantees for the launch contract. Both Checkout and non-Checkout incomplete subs are blocked | subscriptions overview |
| Already terminal | subscription.status === 'canceled' or 'incomplete_expired' | ”After it’s canceled, you can no longer update the subscription or its metadata.” No mutation is valid; widget shows the truth instead of a manual request | subscriptions overview |
| Already scheduled to cancel | cancel_at_period_end === true or cancel_at !== null | Already canceling. Widget shows the existing scheduled-cancel date and records the visit; no new mutation, no new request | subscriptions overview |
| Unrecognized future Stripe status | Any status not matched by the audited resolver lanes | The cancel resolver is fail-closed: it must not call Stripe when it cannot prove which other rule applies. Unknown future Stripe statuses, zero-item defensive shapes, and any unaudited state route to manual | subscriptions overview |
Shapes that block retention offers but allow automated cancel
These shapes route to manual for retention offers (discount, pause, plan-switch, trial extension) but allow automated cancel through the standard cancel_at_period_end path. The cancel confirmation may carry a final-billing caveat depending on the shape.
| Shape | What we read | Why it blocks retention offers | Stripe docs |
|---|---|---|---|
automatic_tax.enabled === true | subscription.automatic_tax.enabled | The widget tile shows pre-tax unit_amount; the customer would pay unit_amount + tax. This is a copy lie. Automated cancel does not quote a future amount, so it is allowed | subscriptions overview |
| Multi-currency price on either side (current or target) | currency_options non-empty on current.price or target.price | Per-currency unit_amount can drift independently. The customer settles in subscription.currency; if that is a currency_options entry rather than the price’s default, our default-currency comparison can be inverted in the settlement currency | subscriptions overview |
| Async payment method | default_payment_method.type in {us_bank_account, sepa_debit, au_becs_debit, bacs_debit, acss_debit, customer_balance, upi, klarna, paypal, link} | Async PMs keep status='active' after a failed payment, so active is not a reliable “paid” signal for retention copy. Klarna, PayPal, and Link have insufficiently documented renewal-failure semantics | subscriptions overview |
| India recurring card | default_payment_method.card.country === 'IN' | RBI-mandated 24h pre-debit notification + Stripe’s 26h delay + AFA on each charge ≥ 15,000 INR. Retention timing assumptions do not match | subscriptions overview |
| Multi-item subscription (retention offers) | items.data.length > 1 | Retention copy is single-item per sub. Multi-item subs route to manual on cancel intent (see above table) | subscriptions overview |
Multi-seat (quantity > 1) | items.data[0].quantity > 1 | Retention copy needs seat-aware economics. Plan-switch silently resets quantity to 1 unless explicitly preserved. Cancel may proceed because it cancels the whole item | subscriptions overview |
| Metered usage | items.data[0].price.recurring.usage_type === 'metered' | Metered usage has final-usage and billing-meter semantics that break “next invoice” or “free until” copy. Cancel may proceed with a final-usage caveat | subscriptions overview |
Tiered or non-per_unit pricing | items.data[0].price.billing_scheme !== 'per_unit' | Tiered pricing requires tiers[] math for “show price” UI; not in scope for retention copy | subscriptions overview |
| Decimal-only, custom-amount, or transformed price | Any of: unit_amount === null, custom_unit_amount !== null, transform_quantity !== null, tiers_mode !== null, type !== 'recurring', target.active === false | ”Strictly cheaper” is only deterministic for integer per-unit licensed prices. Decimal-only, customer-entered, transformed, and tiered prices require invoice preview | subscriptions overview |
pending_invoice_item_interval set | subscription.pending_invoice_item_interval !== null | Stripe can invoice pending items on a recurring interval that is not the normal recurring price. Subscription-update prorations do not include pending invoice items. “Next invoice” copy can be false | subscriptions overview |
| Pending one-off invoice items | invoiceItems.list({ customer, pending: true }) returns rows for this sub | Pending invoice items can be added to future invoices or pulled into one-off invoices. “Next invoice”, “billing paused”, and “free until” copy can be false | invoiceitems list |
| Unresolved subscription invoices | Any sub invoice with status not in {paid, void} | pause_collection only voids invoices generated after the pause takes effect; existing invoices continue retrying. Discount applies to future invoices and does not rewrite already-finalized draft or open invoices | pause-payment , coupons |
| Existing active discount on any surface | customer.discount, subscription.discounts, or any subscription.items[].discounts non-empty | Stacking is not supported. Customer-level, subscription-level, and item-level discount surfaces are all checked. Pause does not extend coupon validity, so a discount can expire unused during a pause | coupons |
| Coupon duration misaligned with cadence | Non-monthly sub + repeating coupon where duration_in_months < billing_interval_in_months | Coupon duration is time-based, not invoice-count-based. A 3-month repeating coupon on a 9-month-out annual renewal reduces zero invoices | coupons |
| Trialing + repeating discount | status === 'trialing' + coupon duration === 'repeating' | A repeating coupon can expire before the first paid invoice if applied during a long trial. Trialing subs accept duration='once' only | coupons |
| Attached Trial Offer marker | Any subscription.items[].current_trial.trial_offer | Stripe treats Trial Offers as a separate trial model from legacy subscription.trial_end. Unchurn extends only legacy trials | subscriptions overview |
send_invoice collection method (price-bearing offers) | subscription.collection_method === 'send_invoice' | Different invoice finalization, payment timing, and dunning semantics from charge_automatically. Discount, pause, plan-switch, and trial-extension are price-bearing and require automatic card collection. Cancel may proceed | subscriptions overview |
| No payment method on file | Both subscription.default_payment_method and customer.invoice_settings.default_payment_method are null | No PM on file means renewal will fail. Retention offers are meaningless until a PM is attached | subscriptions overview |
Plan-switch-specific blocks
In addition to the global blocks above, plan-switch enforces these checks against the target price.
| Block | What we read | Why |
|---|---|---|
| Currency mismatch | target.currency !== current.currency | Stripe rejects mid-sub currency change |
| Cadence mismatch | target.recurring.interval !== current.recurring.interval or interval_count differs | Cross-cadence switch resets billing_cycle_anchor in classic mode and may bill immediately |
| Tax-behavior mismatch | target.tax_behavior !== current.tax_behavior | tax_behavior is immutable per price; switching inclusive ↔ exclusive changes the actual amount paid at the same headline price |
| Target inactive | target.active === false | Cannot move customers onto an inactive price |
| Target not strictly cheaper | target.unit_amount >= current.unit_amount | Plan-switch is downgrade-only |
Target not on merchant’s allowed_transitions list | Missing plan_switch.allowed_transitions[current_price_id] ⊇ target_price_id | Product names and metadata are not entitlement proof. Each downgrade path must be explicitly approved by the merchant |
| Multi-currency on target | target.price.currency_options non-empty | Per-currency unit_amount can drift independently — see the multi-currency row above |
See also
- Unusual subscriptions — what happens when a sub is routed to manual
- Per-offer eligibility — which dimensions block which retention offer
- Manual cancellation request — the customer + merchant UX
Last updated on