Skip to main content
Back to AI Commerce Lab
Architecture·November 2025·9 min read

The Future of Subscription Commerce: Architecture, UX, and the Art of Retention

Stripe's own subscription object moves through 8 documented statuses, and past_due isn't a UX afterthought — it's a state your access-control logic has to handle explicitly. Subscription commerce is a state machine problem wearing a billing-page costume, and most of the customer-facing UX failures trace back to a team that never modeled the states in the first place.

The subscription lifecycle is a state machine, not a checkout screen

Stripe's subscription lifecycle documentation defines eight statuses: trialing, active, incomplete, incomplete_expired, past_due, unpaid, canceled, and paused. Each one implies a different access-control decision, and treating them as a single "is subscribed" boolean is where most home-grown billing systems start leaking revenue or angering customers.

When a subscription requires an immediate payment, Stripe creates an invoice with a 23-hour payment window. During that window the subscription sits in incomplete; if the customer doesn't pay in time, it moves to incomplete_expired and a new subscription has to be created from scratch, since Stripe won't resurrect an expired one.

Provisioning access without polling

Stripe's guidance is explicit: use webhook events to track status transitions rather than polling the API. Listen for invoice.paid to grant access and invoice.payment_failed to start your own dunning-adjacent messaging, and always verify webhook signatures before trusting the payload — Stripe notes that sensitive information is never included in a webhook event, and that you should allowlist Stripe's IP ranges on top of signature verification.

active doesn't mean every invoice for that subscription is paid. Stripe's own documentation warns that a subscription can be active while other outstanding invoices remain open — access control has to check the specific invoice, not just the subscription's headline status.

Dunning is a scheduling problem before it's a UX problem

Involuntary churn — a card expiring, a bank declining a routine charge — is recoverable in most cases, and recovery is fundamentally a retry-timing problem. Stripe's Smart Retries uses signals like the number of devices that have presented a payment method recently and historical success-rate patterns by time of day to pick retry windows, defaulting to 8 attempts within 2 weeks, configurable from 1 week up to 2 months.

Retries aren't attempted against every decline. Stripe won't retry against a defined list of hard decline codes, because retrying them can't succeed without new information from the customer:

  • lost_card, stolen_card, pickup_card — the card itself is flagged
  • revocation_of_authorization, revocation_of_all_authorizations — the customer withdrew permission
  • authentication_required — needs a fresh 3D Secure challenge, not a silent retry
  • transaction_not_allowed, highest_risk_level — the issuer is actively blocking the transaction

When a hard decline occurs, Stripe still increments the retry counter and continues scheduling attempts, but nothing actually executes until a new payment method is attached. A dunning email campaign that doesn't distinguish hard declines from soft ones is asking customers to fix a problem your system already knows won't resolve itself.

A minimal webhook handler for payment failures

// invoice.payment_failed handler (Node, pseudocode)
app.post('/webhooks/stripe', async (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.rawBody, req.headers['stripe-signature'], endpointSecret
  );

  if (event.type === 'invoice.payment_failed') {
    const invoice = event.data.object;
    const isHardDecline = HARD_DECLINE_CODES.has(invoice.last_payment_error?.decline_code);

    if (isHardDecline) {
      await notifyCustomer(invoice.customer, 'update-payment-method');
    } else {
      await logRetryScheduled(invoice.id, invoice.next_payment_attempt);
    }
  }
  res.sendStatus(200);
});

Retry priority also matters: Stripe attempts the subscription's own default payment method first, then falls back to the customer's default, in a fixed order. Updating the wrong field after a failed payment — the customer's default instead of the subscription's — means Stripe keeps retrying the card that already failed.

Upgrades, downgrades, and pauses are the same state machine

Stripe lets you modify an existing subscription in place — changing price, pausing collection — without canceling and recreating it. Building a separate code path for "change plan" instead of routing it through the same subscription object creates two sources of truth for the same customer, and they will eventually disagree.

The same discipline applies to pausing. A paused subscription (end-of-trial with no payment method attached) stops generating invoices entirely rather than failing silently, which matters for any dashboard reporting on active subscriber counts.

Build vs buy: Stripe Billing, Shopify Subscriptions, or custom

Shopify's subscription model splits across three APIs: selling plan APIs for delivery, pricing, and billing policy definitions, subscription contract APIs for the actual agreement between merchant and customer, and customer payment method APIs for stored credentials. That's a native fit if the storefront is already on Shopify and the subscription logic doesn't need to diverge from what selling plans support.

ApproachFits whenTrade-off
Shopify Subscriptions (selling plans)Storefront already on Shopify, subscription rules fit standard delivery/pricing/billing policiesCustomization is bounded by what selling plan and contract objects expose
Stripe Billing (headless)Custom storefront, complex plan logic, need direct control over invoicing and dunningYou own more of the UX and access-control wiring yourself
Custom-built billing engineRequirements no platform supports — usage-based hybrid models, non-standard prorationFull PCI scope exposure and dunning logic to build and maintain indefinitely

Custom-built billing is the right call less often than teams assume. Every dunning edge case above — hard decline codes, retry ordering, the 23-hour incomplete window — is already solved and battle-tested inside Stripe or Shopify. Reimplementing it is a multi-quarter project with a long tail of edge cases that only show up in production, at customers' expense.

Migration sequencing: cutting over without a billing outage

Moving an active subscriber base from a home-grown system to Stripe or Shopify fails most often when it's treated as a single cutover event instead of a phased rollout. The subscription state machine has to exist on both sides during the transition, or every in-flight renewal becomes a support ticket.

  1. Map every existing subscription status in the legacy system to its equivalent Stripe or Shopify status before writing any migration code
  2. Run a dual-write period where new signups go to the new platform while renewals still process on the old one
  3. Backfill historical subscribers in batches, starting with the lowest-risk segment — trialing or recently-started subscriptions, not customers mid-dunning cycle
  4. Reconcile invoice totals between systems for at least one full billing cycle before decommissioning the legacy path
  5. Cut over renewal processing only after a batch has completed a full cycle cleanly on the new platform
The riskiest subscribers to migrate first are the ones already mid-dunning. Move them last, after the new platform's retry and webhook wiring has proven itself against a full billing cycle of low-risk accounts.

The compliance floor for cancellation

The Restore Online Shoppers' Confidence Act sets three requirements for any negative-option subscription in the US: clear and conspicuous disclosure of material terms before billing information is collected, express informed consent before the first charge, and a simple mechanism to stop recurring charges. These aren't UX suggestions — they're the statutory basis for the FTC's enforcement actions against subscription businesses with buried cancellation flows.

  1. Disclose price, billing frequency, and free-trial end date before the payment form, not after
  2. Collect explicit consent — a checkbox or signature — and keep proof of it for your own records
  3. Build cancellation in the same channel signup used: if signup was two clicks online, cancellation can't require a phone call

PCI scope: what "save this card" actually commits you to

The PCI Data Security Standard applies to anyone who stores, processes, or transmits cardholder data, and full compliance can mean meeting more than 300 security controls if your servers ever touch a raw card number. Most subscription businesses never need to accept that scope.

Stripe operates as a certified PCI Level 1 Service Provider, and its low-risk integration patterns — Elements, Checkout, tokenized payment methods — route card data directly to Stripe without it passing through your servers, which keeps you eligible for the lightest self-assessment questionnaire tier instead of a full annual audit. Non-sensitive fields returned after a charge (card brand, last four digits, expiry date) aren't subject to PCI scope and can be stored freely for display purposes.

  • Never let a raw PAN or CVV touch your own servers or logs — use a tokenizing SDK for every card-entry surface
  • Serve every payment page over TLS 1.2 or above, including every script and asset loaded on that page
  • Store only the display-safe fields (brand, last four, expiry) your provider marks as out of PCI scope

What we build for clients migrating off home-grown billing

Model the state machine first, before a single UI screen. If a team can't draw every subscription status and the access-control rule attached to each one, the migration isn't ready to start.

Then wire dunning to the platform's own retry logic and hard-decline list instead of a custom cron job. Most home-grown billing systems we've inherited were retrying every decline the same way, silently costing the client both processor fees and customer goodwill on charges that were never going to succeed.

FAQ

What does Stripe's past_due status actually mean for access control?

The latest finalized invoice failed or wasn't attempted, but the subscription keeps generating new invoices. Whether to keep or revoke product access during past_due is a business decision your app has to encode explicitly — Stripe doesn't make it for you.

How many times does Stripe retry a failed subscription payment by default?

The default Smart Retries setting is 8 attempts within 2 weeks, though it's configurable from 1 week up to 2 months, and can vary by customer segment using automations.

Should we build our own dunning emails or rely on the platform's?

Rely on the platform's retry scheduling and hard-decline detection, then layer your own branded messaging on top of the webhook events it fires. Rebuilding the retry logic itself duplicates work Stripe or Shopify has already hardened.

Does using Stripe or Shopify eliminate our PCI obligations entirely?

No. PCI compliance is a shared responsibility — the platform is certified as a service provider, but your business still has to accept payments in a PCI-compliant manner and attest to it annually, typically at the lightest self-assessment tier if you use tokenized, low-risk integrations.

What's the minimum cancellation flow that satisfies ROSCA?

Clear disclosure of terms before payment, explicit consent captured and retained, and a cancellation mechanism at least as simple as the signup flow, offered in the same channel the customer used to sign up.

Is Shopify Subscriptions a good fit for a headless storefront?

It can be, through the Storefront API and selling plan objects, but complex or non-standard billing logic often outgrows what selling plans and subscription contracts expose, which is when a Stripe Billing–based headless approach earns its added integration work.

References

From the Destm engineering archive. For current work on this topic, start at Solutions or the blog.