Photo by chansu shin on Unsplash

On this page

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

routes/payment-intent.js
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

public/checkout.html
<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>
public/checkout.js
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

  1. 1. What does the server return to the browser for the Payment Element?

Search articles

Type a word to search published articles.

Open the search page