Per-offer eligibility
Reference tables. One per offer. Each table lists the conditions a subscription must satisfy for the offer to surface, and the conditions that block it.
Eligibility is per-offer, not all-or-nothing. A single subscription can be eligible for cancel and discount but blocked from pause (because it is annual) and plan-switch (because no merchant-approved downgrade exists).
For the comprehensive list of subscription shapes that block automated cancel, see blocked subscription shapes.
Cancel
Cancel has the loosest constraints, but it is not unconditional. When automated cancel is blocked, the cancel CTA still works — it records a manual cancellation request instead of mutating Stripe.
Stripe primitive: subscriptions.update({ cancel_at_period_end: true })
Eligible when:
| Condition | Required value |
|---|---|
| Status | active or trialing |
| Already canceling | cancel_at_period_end === false and cancel_at === null. If already canceling, the widget shows the existing date and does not mutate again. |
| Pause collection | pause_collection === null (foreign or manual collection pauses route to manual) |
| First-class paused status | status !== 'paused' |
| Schedule | schedule === null |
| Cadence | cadence === null (Stripe v2 preview primitive — undocumented behavior with retention mutations) |
| Pending update | pending_update === null |
| Items | items.data.length === 1 AND items.has_more === false (paginated multi-item shapes are also blocked) |
| Status finality | Not in canceled, incomplete_expired, past_due, unpaid, incomplete |
Routed to manual cancellation request when:
| Reason | Why |
|---|---|
Subscription is delinquent or in setup (past_due, unpaid, incomplete) | Period boundaries and dunning state make “scheduled for period end” ambiguous; we do not mutate dunning state automatically |
| First-class paused subscription | Stripe’s resume path may create a resumption invoice; cancel semantics on a true status='paused' sub are not part of the launch contract |
Foreign or manual pause_collection is set | Cancel semantics on a paused-collection sub set by another tool are outside the launch contract |
| Pending update is queued | Out-of-band pending updates interleave unsafely with mutations; per-mutation interaction is unverifiable in Stripe docs |
| Multi-item or paginated multi-item subscription | Per-item billing periods break a single-date cancellation copy; mixed-interval items have separate final-invoice semantics |
| Schedule-attached subscription | A direct cancel_at_period_end may be overwritten by the next phase boundary or conflict with the schedule’s end_behavior. The safe path (subscriptionSchedules.release then cancel) is roadmap |
| Cadence-attached subscription (Stripe v2 preview) | Cancel behavior on cadence-attached subs is undocumented |
Already terminal (canceled, incomplete_expired) | No mutation is valid; widget shows the truth |
| Unrecognized future Stripe shape | Resolver fails closed when it cannot prove which other rule applies |
Discount
Applies a freshly-created session-specific coupon (max_redemptions=1, redeem_by=now+1h) to the customer’s next eligible invoice.
Stripe primitive: coupons.create then subscriptions.update({ discounts: [{ coupon }] })
Eligible when:
| Condition | Required value |
|---|---|
| Status | active or trialing. Trialing subs receive duration='once' coupons only |
| Cadence | Any single-item cadence |
| Items | Single-item, single-seat (items.data.length === 1, quantity === 1) |
| Price shape | Licensed (recurring.usage_type === 'licensed'), per-unit (billing_scheme === 'per_unit'), integer unit_amount, single-currency (currency_options empty), no custom_unit_amount, no transform_quantity, no tiers_mode |
| Collection method | charge_automatically |
| Automatic tax | automatic_tax.enabled === false |
| Payment method | Card, country !== 'IN' |
| Pending state | No schedule, no pending_update, no pending_invoice_item_interval, no pending one-off invoice items, no cadence |
| Invoice state | No unresolved invoices on the sub (every invoice is paid or void) |
| Existing discount | None on customer.discount, subscription.discounts, or any subscription.items[].discounts |
| Cooldown | Discount cooldown not active for this customer |
| Coupon duration vs cadence | For non-monthly subs, only duration='once' is allowed unless duration_in_months >= billing_interval_in_months. Coupon validity is time-based, not invoice-count-based |
| Trialing + repeating | Trialing subs may not receive duration='repeating' coupons (the coupon window can expire before the first paid invoice) |
Ineligible when:
| Reason | Why |
|---|---|
| Subscription already has any active discount | Stacking is not supported. Customer-level, subscription-level, and item-level discount surfaces are all checked |
| Multi-item or multi-seat subscription | Discount copy assumes single-item, single-seat economics |
| Tiered pricing, custom amount, decimal-only price, transformed quantity, or multi-currency price | ”Show price” math is non-deterministic without invoice preview |
| Automatic tax enabled | Widget tile shows pre-tax unit_amount; customer would be charged post-tax — copy lie |
| Async payment method or India card | Async PMs (ACH, SEPA, BECS, Bacs, ACSS, customer balance, UPI, Klarna, PayPal, Link) keep status='active' after a failed payment, so active is not a reliable signal. India recurring cards have RBI-mandated 24h pre-debit notification + Stripe 26h delay |
| Unresolved invoices on the sub | Discount applies to future invoices; it does not rewrite already-finalized draft or open invoices |
pending_invoice_item_interval is set, or pending one-off invoice items exist | ”Next invoice” copy can be false because pending items can be invoiced separately |
Schedule-attached, cadence-attached, or pending_update | Mutations can be overwritten by the next phase or interleave unsafely |
| Trialing + repeating coupon | Time-based coupon can expire during the trial before the first paid invoice |
| Cooldown active | Configured per-customer cooldown prevents discount fatigue |
Source: coupons , subscriptions overview .
Pause
Pauses invoice collection for a fixed window. New invoices generated during the pause are voided immediately. The subscription itself stays status='active'.
This is not Stripe’s first-class status='paused' API. The two are separate primitives and do not share a resume path.
Stripe primitive: subscriptions.update({ pause_collection: { behavior: 'void', resumes_at } })
Eligible when:
| Condition | Required value |
|---|---|
| Status | active |
| Cadence | Monthly only (interval === 'month' AND interval_count === 1) |
| Items | Single-item, single-seat |
| Price shape | Licensed, per-unit, integer unit_amount, single-currency, no custom/decimal-only/tiered/transformed shape |
| Collection method | charge_automatically |
| Automatic tax | automatic_tax.enabled === false |
| Payment method | Card, country !== 'IN' |
| Pending state | No schedule, no pending_update, no pending_invoice_item_interval, no pending one-off invoice items, no cadence |
| Invoice state | No unresolved invoices on the sub (every invoice is paid or void) |
| Discounts | None on customer, subscription, or items |
| Cancel state | cancel_at_period_end === false and cancel_at === null |
| Pause state | pause_collection === null (already-paused subs use the resume path, not re-pause) |
| Cooldown | Pause cooldown not active |
Ineligible when:
| Reason | Why |
|---|---|
| Annual, quarterly, or any non-monthly cadence | Pausing across a renewal date forfeits revenue. Annual pause is monthly-only at launch and is on the roadmap |
Status is not active | Pause requires status='active' — trialing, past_due, unpaid, and paused subs are all blocked |
| Pre-existing unresolved invoices | pause_collection only voids invoices generated after the pause takes effect. Existing draft or open invoices continue to be retried unless voided separately |
| Multi-item or multi-seat | Per-item or per-seat pause copy is not supported |
| Existing discount on any surface | Pause does not extend coupon validity, so a discount can expire unused during the pause |
| Async PM or India card | Same async-PM and RBI timing concerns as discount |
Schedule-attached, cadence-attached, or pending_update | Pause behavior interleaves unsafely with phase transitions or queued updates |
Already paused via pause_collection | Use resume; do not re-pause |
First-class status='paused' | Different primitive; not addressed by this offer |
Source: pause-payment — Stripe’s own copy is “the subscription’s status remains unchanged” and “invoices created before subscriptions are paused continue to be retried unless you void them.”
Plan-switch (downgrade)
Migrates the customer to a strictly cheaper monthly plan. The new price applies at the next invoice. Proration is forced to 'none' — no immediate charge or credit. No customer-balance side effects across other subscriptions.
Stripe primitive: subscriptions.update({ items: [{ id, price, quantity }], proration_behavior: 'none' })
Eligible when:
| Condition | Required value |
|---|---|
| Status | active |
| Cadence | Monthly only on both current and target prices |
| Items | Single-item, single-seat |
| Current price shape | Licensed, per-unit, integer unit_amount, single-currency, no custom/decimal/tiered/transformed shape |
| Target price shape | Same as current. Plus target.active === true |
| Currency | target.currency === current.currency (lowercase compare) |
| Cadence match | target.recurring.interval === current.recurring.interval AND interval_count matches |
| Tax behavior | target.tax_behavior === current.tax_behavior (immutable per price; switching inclusive ↔ exclusive changes the customer’s actual paid amount even at the same headline price) |
| Strictly cheaper | target.unit_amount < current.unit_amount (integer compare, default currency) |
| Multi-currency on either side | Both current.currency_options and target.currency_options must be empty/null |
| Merchant approval | Exact pair current_price_id -> target_price_id exists in the merchant’s plan_switch.allowed_transitions configuration |
| Automatic tax | automatic_tax.enabled === false |
| Pending state | No schedule, no pending_update, no pending_invoice_item_interval, no pending one-off invoice items, no cadence |
| Discounts | None on any surface |
| Payment method | Card, country !== 'IN' |
| Cancel state | Not already canceling |
| Pause state | Not already paused |
| Proration | Forced to 'none' regardless of merchant config (avoids cross-sub customer.balance subsidy) |
Ineligible when:
| Reason | Why |
|---|---|
| Annual or non-monthly subscription | Plan switch is monthly-only at launch. Roadmap |
Upgrade attempt (target.unit_amount >= current.unit_amount) | Plan-switch is downgrade-only |
| Cross-cadence switch (monthly ↔ annual) | Stripe resets billing_cycle_anchor in classic mode and may bill immediately at the new interval |
| Currency mismatch | Stripe rejects mid-sub currency change |
| Tax-behavior mismatch | The same headline price can charge a different actual amount |
| Multi-currency price on current OR target | unit_amount can drift independently per currency; the comparison can be inverted in the settlement currency, silently moving the customer to a more expensive plan |
Target price not on the merchant’s allowed_transitions list | Product names and metadata are not entitlement proof. Each downgrade path must be explicitly approved |
| Multi-item, multi-seat, or metered | Quantity reset risk on update; metered usage has separate final-usage semantics |
Tiered, decimal-only, transformed, or non-per_unit price on either side | ”Strictly cheaper” is only deterministic for integer per-unit prices |
| Automatic tax enabled | Same copy-lie risk as discount |
| Existing discount, schedule, pending update, cadence, pending invoice items | Same interleave/preservation risks as discount |
Source: billing-cycle , customer balance — credit prorations land in customer.balance and auto-apply to the next finalized invoice on any sub for that customer, which is why proration is forced to 'none'.
Trial extension
Extends the customer’s trial by N days (configured per merchant). The next billing date shifts based on Stripe’s billing-mode rules.
Stripe primitive: subscriptions.update({ trial_end, proration_behavior: 'none' })
Eligible when:
| Condition | Required value |
|---|---|
| Status | trialing |
| Trial remaining | trial_end > now + 24h |
| New trial end | new_trial_end <= billing_cycle_anchor + 2y (Stripe’s 2-year hard cap). Per-offer config caps daysExtended at 1–30 days. |
| Items | Single-item, single-seat, licensed per-unit |
| Discounts | None on any surface |
| Pending state | No schedule, no pending_update, no pending_invoice_item_interval, no pending one-off invoice items, no cadence |
| Trial offer marker | No subscription.items[].current_trial.trial_offer (first-class Trial Offers are a separate model) |
| Budget | Per-merchant per-customer extension budget remaining (typically one extension per customer in live mode) |
| Cancel state | Not already canceling |
| Payment method | Card, country !== 'IN' |
| Automatic tax | automatic_tax.enabled === false |
Ineligible when:
| Reason | Why |
|---|---|
| Trial has less than 24 hours remaining | Customer is past the decision window for the offer to be meaningful |
| Customer has already received an extension | Compounding extensions break per-customer budgets and stack into Stripe’s 2-year anchor cap |
| Existing discount on any surface | Compounding incentives become hard to reason about; coupon validity is time-based and may expire mid-extension |
| Attached first-class Trial Offer | Stripe treats Trial Offers as a separate model from legacy trial_end extension |
Pending invoice items, pending invoice-item interval, or pending_update | ”Free until” copy can be false |
| Schedule-attached or cadence-attached | Phase transitions can overwrite the new trial_end |
| Multi-item, multi-seat, or non-licensed price | Single-item, single-seat is the supported retention shape |
The next-invoice timing after extension depends on subscription.billing_mode.type. In classic mode, billing_cycle_anchor aligns to the new trial_end. In flexible mode, the anchor is unchanged. The widget makes no anchor-movement promise it cannot read back from Stripe — see billing-cycle .