Module 3: Checkout Flows
Your checkout is where money actually changes hands. There are three ways to take a card payment on Stripe — hosted Checkout, no-code Payment Links, and a fully custom build with Stripe Elements. Each one has a precise cost profile: speed of setup vs control over branding vs conversion tuning. This module shows you how to pick, how to build each, and how to handle the hard parts: 3D Secure, wallets, and mobile conversion.
Checkout vs Payment Links vs Elements: picking your weapon
Stripe gives you three checkout paths. Payment Links are no-code: Dashboard → Payments → Payment links → New, set a price, copy the URL, and you can sell in under five minutes. You get zero branding control beyond a logo and accent color, no upsells, no custom fields beyond a fixed set. Use them for one-off invoices, DM sales, beta cohorts — anywhere speed beats polish.
Stripe Checkout is the hosted page you redirect customers to. Create a Checkout Session via API or dashboard, send the customer to the session URL, Stripe handles cards, wallets, SCA, taxes, coupons, and 3D Secure. Conversion-wise it beats a hand-rolled form for most businesses because Stripe runs continuous optimization across billions of sessions. Trade-off: the customer leaves your site (though branded Checkout with your logo and colors closes most of that gap), and you cannot run multi-step flows or upsells inside it. Pricing models that fit Checkout: fixed-price products, subscriptions with trials, pay-what-you-want.
Stripe Elements is the custom route: you build your own form with Stripe's pre-built card input components. Full branding control, inline on your page, upsells and order bumps anywhere you want. Cost: you own the form's conversion performance, error handling, wallet buttons, and SCA flows. Use Elements when your checkout is a competitive advantage — high-ticket funnels, checkout-embedded trials, or anywhere the design must match your site exactly.
Decision rule of thumb: sell in an afternoon? Payment Links. Professional storefront with zero maintenance? Checkout. Checkout is a conversion lever you tune weekly? Elements. And nothing stops you from mixing: Payment Links for invoices, Checkout for the main offer, Elements for the flagship funnel.
You are launching a $29 workshop in 48 hours and the only requirement is a working "buy" link. Which path do you choose?
Building a custom checkout with Stripe Elements
A custom Elements checkout is three pieces: load stripe.js, mount the card component, and confirm a PaymentIntent. The server creates the PaymentIntent first — that is where amount, currency, and your metadata live. Never trust the amount from the client. The server returns the PaymentIntent's client_secret; the frontend uses it exactly once with stripe.confirmCardPayment.
Server side (Node.js):
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const paymentIntent = await stripe.paymentIntents.create({
amount: 4900, // cents — always integer minor units
currency: 'usd',
automatic_payment_methods: { enabled: true, allow_redirects: 'never' },
metadata: { order_id: 'ORD-1042' },
});
res.json({ clientSecret: paymentIntent.client_secret });Frontend:
const stripe = Stripe('pk_test_YOUR_PUBLISHABLE_KEY');
const elements = stripe.elements();
const card = elements.create('card', { style: { base: { fontSize: '16px' } } });
card.mount('#card-element');
const res = await fetch('/create-payment-intent', { method: 'POST' });
const data = await res.json();
const clientSecret = data.clientSecret;
const result = await stripe.confirmCardPayment(clientSecret, {
payment_method: { card: card, billing_details: { name: 'R. Customer' } },
});
if (result.error) { showError(result.error.message); } // declined, expired, etc.
else if (result.paymentIntent.status === 'succeeded') { showSuccess(); }The PaymentIntent lifecycle matters because your webhook logic reads it. requires_payment_method means no usable card yet — re-prompt the user. requires_action means the bank wants authentication (3D Secure) — confirmCardPayment handles it in-place with an iframe, and you must not treat this as failure. processing is the transient wait. succeeded is final. canceled and requires_capture (authorize-now-capture-later) round out the set. Your payment_intent.succeeded webhook is the source of truth for fulfillment — never fulfill on the frontend's status alone, since the user can close the tab before the event lands.
Pitfalls: mount Elements only after stripe.js loads (it loads async); a 16px base font size stops iOS from zooming the card field; and handle payment_intent.payment_failed to log decline codes — card_declined, insufficient_funds, expired_card — so your error copy can coach the customer ("try another card") instead of a generic failure.
Before launch, exercise the full lifecycle in test mode with Stripe's test cards: 4242 4242 4242 4242 succeeds, 4000 0000 0000 0002 declines with card_declined, and 4000 0000 0000 3220 forces the 3D Secure challenge covered in the next lesson. Walk each one end to end — create the PaymentIntent, confirm, watch the webhook land — and confirm your success page only renders after the webhook-verified fulfillment, not on the frontend callback. That single discipline eliminates the most common custom-checkout bug: orders marked paid that Stripe never settled.
To confirm a card payment with Stripe Elements, your frontend needs the PaymentIntent's .
3D Secure and Strong Customer Authentication
Strong Customer Authentication (SCA) is the EU/UK regulation requiring two-factor authentication on most online card payments — typically something the customer knows plus something they have, rendered as the familiar bank pop-up asking for an SMS code or app approval. In the US it is not legally required, but US issuers still trigger 3D Secure on high-risk transactions, and any business with European customers must handle it or watch payments fail with authentication_required.
What triggers it in practice: cards issued in the EEA/UK on transactions over the SCA threshold, issuers' risk engines flagging unusual amounts or new devices, and in test mode the famous 4000 0000 0000 3220 card, which forces authentication on demand — use it in your staging environment to prove your flow works before launch. When authentication is required, the PaymentIntent moves to requires_action and Stripe needs to present the challenge.
The code path is simpler than people fear. With Elements, stripe.confirmCardPayment(clientSecret) automatically renders the 3D Secure challenge in an iframe overlay and resumes the payment when the customer completes it — you do not build a redirect yourself. What you must build is the requires_action branch on the server side for any flow where you confirm server-side:
// Server-side confirm (e.g. charging a saved card off-session)
const intent = await stripe.paymentIntents.confirm(piId, {
payment_method: savedPaymentMethodId,
off_session: true, // customer is not in your checkout flow
});
if (intent.status === 'requires_action') {
// Cannot complete off-session. Bring the customer back to authenticate,
// then re-confirm client-side with the client secret.
return res.status(402).json({
requiresAction: true,
clientSecret: intent.client_secret,
});
}Off-session failures are the number-one SCA surprise for subscription businesses: a saved card that worked fine for months suddenly declines with authentication_required because the issuer wants a fresh check. Your retry and dunning logic must route those customers back into an on-session authentication — the customer portal's card-update flow or an email link that re-confirms. Also note the exemption path: Stripe's payment_intent object carries charge outcome data; traffic you mark as low-risk via Radar can qualify for transaction-risk-analysis exemptions, but never assume an exemption — code for the challenge.
Edge case: some issuers' challenge pages break inside iframed checkouts on older mobile browsers. If your analytics show abandoned requires_action spikes on Android WebView, test the redirect-based challenge by setting return_url in your confirm call and letting Stripe do a full-page handoff instead.
A recurring charge on a saved card fails with code authentication_required. What happened?
Apple Pay, Google Pay, and mobile conversion
Wallets convert. Apple Pay and Google Pay remove typing entirely — the customer authenticates with Face ID or a fingerprint and the payment details are tokenized straight into Stripe. On mobile, where wallets are native, enabling them is the single highest-leverage checkout change you can make. Stripe exposes them through the Payment Request Button, an Elements component that detects which wallets the customer's device supports and renders the right one automatically.
const paymentRequest = stripe.paymentRequest({
country: 'US',
currency: 'usd',
total: { label: 'Workshop ticket', amount: 4900 },
requestPayerName: true,
requestPayerEmail: true,
});
const prButton = elements.create('paymentRequestButton', { paymentRequest });
const canPay = await paymentRequest.canMakePayment();
// canPay is null when NO wallet is available — hide the button.
if (canPay) { prButton.mount('#payment-request-button'); }
paymentRequest.on('paymentmethod', async (ev) => {
const result = await stripe.confirmCardPayment(
clientSecret,
{ payment_method: ev.paymentMethod.id },
{ handleActions: false }
);
if (result.error) { ev.complete('fail'); }
else {
ev.complete('success');
if (result.paymentIntent.status === 'requires_action') {
// 3D Secure still needed — confirm again WITH action handling
const again = await stripe.confirmCardPayment(clientSecret);
if (again.error) showError(again.error.message); else showSuccess();
} else showSuccess();
}
});Apple Pay has one hard requirement that fails silently: domain verification. You must serve Apple's verification file at https://yourdomain.com/.well-known/apple-developer-merchantid-domain-association. In the dashboard: Settings → Payments → Apple Pay → Add a domain, then either upload the file Stripe gives you or let Stripe host it automatically for domains on supported hosts. If verification is missing, canMakePayment() simply returns null on Apple devices — no error, no warning, the button just never appears. When the button is mysteriously absent on iPhones, check this first.
Mobile optimization beyond wallets: use the right inputmode and autocomplete attributes (autocomplete="cc-number", autocomplete="email") so keyboards and autofill do the work; keep the base font at 16px to prevent iOS zoom; put the order summary collapsible so the pay button sits above the fold. And the numbers: industry studies consistently show each additional form field costs roughly 10% of conversions, and a checkout that takes over three minutes loses about a third of starters. One page, card fields plus email, wallets on top — that is the template.
Apple Pay never appears on iPhones at your checkout, but works fine in Stripe's test environment. What is the most likely cause?