Stripe Checkout is convenient, but sometimes the payment form has to be part of your own site: the same fonts and colors, no jump to someone else's address. That is what the Payment Element is for: ready-made protected Stripe fields that you embed in your page. We will connect it to the shop: create a PaymentIntent on the server, hand its client_secret to the browser, confirm the payment with stripe.confirmPayment, deal with statuses, 3-D Secure and card errors, and again leave the final word to the webhook.

Checkout or your own form

Checkout (lesson 039) is a ready-made Stripe page: the simplest and fastest option. The Payment Element is a form inside your page: you design the layout and the text around the field, while the card number is typed into a protected Stripe iframe and never reaches your server (SAQ A, lesson 038). The price of freedom is code: you write the button, the errors and the return after 3-D Secure.

OptionWhere the card is enteredCode
Checkout (hosted_page)on the Stripe pagethe least
Embedded Checkout (embedded_page)Stripe iframe inside your pagelittle
Payment Element + Checkout Sessions (ui_mode: 'elements')Stripe fields in your layoutmedium
Payment Element + PaymentIntentStripe fields in your layoutthe most

💡 An honest note on Stripe's recommendation. Right now (October 2026) the Stripe docs advise Checkout Sessions with the Payment Element for most new integrations: ui_mode: 'elements', and in React CheckoutElementsProvider from @stripe/react-stripe-js/checkout plus checkout.confirm. There is less code, and taxes and discounts come ready. We study PaymentIntent because it also sits underneath Checkout, so statuses, client_secret and webhooks are the same on both paths.

PaymentIntent: the "folder" of one payment

A PaymentIntent is a Stripe object that carries one payment from intention to result: amount, currency, payment method, status, errors. An analogy: a case folder. You open it when the amount is known, the customer puts a card into it, Stripe records what happened. If it fails, the folder stays and the customer tries again.

Together with the folder Stripe gives you a client_secret: the key to that very folder. With it the browser can confirm this payment, but cannot change the amount or see other people's payments. It is not the sk_… secret key, which never leaves the server; still, only the buyer of this order may see the client_secret: it is not logged and not put into a URL.

Payment Element sequence: the browser asks for a PaymentIntent, the server creates it and returns the client_secret, the card goes from the iframe straight to Stripe, then 3-D Secure, the status in the browser and the webhook to the server
Payment Element sequence: the browser asks for a PaymentIntent, the server creates it and returns the client_secret, the card goes from the iframe straight to Stripe, then 3-D Secure, the status in the browser and the webhook to the server

Steps 1-3: the server creates the PaymentIntent and returns only the client_secret to the browser. Steps 4-5: stripe.confirmPayment sends the card data from the iframe straight to Stripe; if needed, Stripe.js opens the 3-D Secure window. Steps 6-7: the browser learns the result, while the real confirmation arrives by webhook.

Step 1. The server creates the PaymentIntent

Let us add a linking field to Order (npx prisma migrate dev --name add_payment_intent_id):