This chapter follows Accepting payments with Stripe Checkout.
Checkout keeps UI on checkout.stripe.com. The Payment Element embeds Stripe’s form in your layout—useful when branding, multi-step flows, or staying on one domain matters. Both paths keep card data off your server via Stripe.js loaded from https://js.stripe.com/v3/.
When you tighten Content Security Policy headers, allow https://js.stripe.com and Stripe’s frame sources per Stripe’s security guide.
Server: create a PaymentIntent
import { stripe } from '../stripe-client.js';import { usersById } from '../store.js';
export async function createPaymentIntent(req, res) { const { userId, amount, currency } = req.body; const user = usersById.get(userId);
const paymentIntent = await stripe.paymentIntents.create({ amount, currency: currency ?? 'usd', customer: user?.stripeCustomerId, automatic_payment_methods: { enabled: true }, metadata: { appUserId: userId }, });
res.json({ clientSecret: paymentIntent.client_secret });}Amounts are integers in the smallest currency unit. Validate amount on the server against your catalog—never trust client-entered prices.
Browser: load Stripe.js and mount the Element
<script src="https://js.stripe.com/v3/"></script><form id="payment-form"> <div id="payment-element"></div> <button type="submit">Pay</button> <p id="message"></p></form>const publishableKey = 'pk_test_REPLACE_ME';const stripe = Stripe(publishableKey);
const res = await fetch('/create-payment-intent', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: 'user_1', amount: 1999, currency: 'usd' }),});const { clientSecret } = await res.json();
const elements = stripe.elements({ clientSecret });elements.create('payment').mount('#payment-element');
document.getElementById('payment-form').addEventListener('submit', async (e) => { e.preventDefault(); const { error } = await stripe.confirmPayment({ elements, confirmParams: { return_url: `${window.location.origin}/complete.html`, }, }); if (error) document.getElementById('message').textContent = error.message;});Serve publishableKey from config rather than hard-coding in source for production.
PaymentIntent statuses and 3D Secure
After confirmation, Stripe may require SCA (3D Secure). confirmPayment handles on-session authentication and redirects to return_url when needed.
Common statuses:
| Status | Meaning |
|---|---|
requires_payment_method |
No valid payment method attached |
requires_confirmation |
Ready to confirm |
requires_action |
Customer must complete authentication |
processing |
Payment in flight |
succeeded |
Paid |
canceled |
Canceled |
Fulfillment still belongs in webhooks (payment_intent.succeeded), not only on complete.html.
Saving a card during payment
Pass setup_future_usage: 'off_session' when creating the PaymentIntent if this checkout should store the method for later:
await stripe.paymentIntents.create({ amount: 1999, currency: 'usd', customer: user.stripeCustomerId, setup_future_usage: 'off_session', automatic_payment_methods: { enabled: true },});See Customers in Stripe for off-session charges.
Checkout versus Payment Element
| Checkout | Payment Element | |
|---|---|---|
| UI location | Hosted by Stripe | Embedded in your pages |
| Integration effort | Lower | Higher (Stripe.js + styling) |
| PCI scope | Low (Stripe-hosted fields) | Low when using Elements |
| Best when | Fast launch, standard checkout | Deep branding, custom flows |
Stripe still recommends Checkout Sessions for many new integrations; Payment Element fits when the hosted page is not acceptable.
Next: Webhooks in Stripe.
Check your understanding
Question 1 of 5
0 of 5 checked