At Streamable, we used Stripe to collect recurring subscription payments. For years, checkout accepted cards through Stripe's Card Element. We wanted to add wallets such as Apple Pay and Google Pay, which made the newer Payment Element the natural replacement.
The frontend migration looked straightforward: both Elements collect a payment method, and Payment Element supports more of them. The difficult part was not rendering a new form. It was choosing the Stripe object whose client secret should initialize that form.
The familiar API was the wrong abstraction
I had previously integrated Stripe for one-off purchases, where a PaymentIntent represented the transaction well. My first instinct was to use the same flow at Streamable: create a PaymentIntent, give its client secret to Payment Element, and confirm it in the browser.
That collected money, but it did not naturally create the relationship we needed. A standalone PaymentIntent is not automatically attached to a subscription. We could build that connection ourselves through an invoice, but then our application would be reconstructing behavior Stripe Billing already knew how to manage.
The better starting point for a subscription that charges immediately was the subscription itself. Creating it with payment_behavior=default_incomplete makes Stripe create the first invoice and its PaymentIntent. Expanding latest_invoice.payment_intent gives the client secret Payment Element needs.
This reverses the mental model. The PaymentIntent does not create the subscription. The subscription creates the invoice, and the invoice creates the PaymentIntent. Payment Element confirms the last object in that chain.
Free trials need a different intent
The flow changes when a subscription starts with a free trial. There is no first payment to make, so there may be no useful PaymentIntent to confirm. What the product needs at signup is permission and a reusable payment method for a charge that will happen later, without the customer present.
That is what a SetupIntent represents. We created one with usage="off_session", let Payment Element collect and authenticate the payment method, and waited for the successful webhook before creating the customer and trial subscription.
The migration therefore had two related but distinct paths:
- Charge now: create an incomplete subscription, take the PaymentIntent from its first invoice, and confirm the payment.
- Charge after a trial: confirm a SetupIntent, save the resulting payment method, then create the subscription for future off-session billing.
Treating both cases as “show a payment form” hides the most important difference: one confirms money moving now, while the other prepares credentials for money to move later.
Existing application state constrained the cleanest flow
Stripe can attach a SetupIntent to a customer before confirmation. That is a clean model because the resulting payment method already belongs to the customer that will own the subscription.
Our codebase made that ordering expensive. The presence of a Stripe customer ID carried assumptions elsewhere in the backend—it implied more of the signup flow had completed than was actually true. Creating a customer merely to initialize Payment Element would have required a broader restructuring.
We instead created the SetupIntent first and carried the plan and user identifiers as metadata. On setup_intent.succeeded, the backend created the customer with the resulting payment method as its default, then created the trial subscription.
This was not the shortest path through Stripe's object model, but it respected the application's existing state machine. Payment integrations are rarely isolated API exercises; the meaning of “customer exists” inside your own system can constrain when a provider object may safely be created.
A successful migration still needed outcome data
After rollout, subscriptions created through Payment Element appeared to convert from free trial to paid at a lower rate than subscriptions created through Card Element. The difference was statistically meaningful enough to investigate, even though both flows looked structurally correct.
We had recorded the checkout integration in subscription metadata, so we could compare cohorts and cross-reference them with charge.failed events. Payment Element subscriptions produced more failures in absolute terms, but the distribution of failure reasons was roughly the same. There was no single error code that explained the conversion gap.
That was an important limit on the conclusion. A correct-looking API integration and similar payment failure categories did not prove the two customer journeys were equivalent. Wallet choice, authentication, payment-method reuse, customer behavior during the trial, and events before normal billing telemetry could all affect the outcome.
We asked Stripe to review the flow and revisited their guidance for deferred subscription payments. The investigation improved our model of the system, but it did not produce a satisfying single cause. Sometimes the honest result of debugging is a narrower set of hypotheses rather than a neat root cause.
What I would carry into the next migration
- Start from the billing lifecycle—pay now or authorize for later—not from the UI component being replaced.
- Let subscriptions create their own invoices and PaymentIntents instead of manually joining standalone payment objects afterward.
- Treat SetupIntents as first-class when trials lead to off-session charges.
- Document what provider-side objects mean inside the application, especially when merely creating a customer changes backend behavior.
- Tag old and new cohorts before rollout, and measure the complete funnel rather than only successful API calls and terminal failures.
The central lesson was simple: Stripe's intents describe different promises. A PaymentIntent is about collecting a payment. A SetupIntent is about preparing a payment method for future use. A subscription coordinates a billing lifecycle around them. The migration became much easier to reason about once we stopped treating those objects as interchangeable ways to obtain a client secret.