Страница Checkout удобна, но иногда форма оплаты должна быть частью вашего сайта: те же шрифты и цвета, без перехода на чужой адрес. Для этого есть Payment Element — готовые защищённые поля Stripe, которые вы встраиваете в свою страницу. Подключим его к магазину: создадим на сервере PaymentIntent, отдадим браузеру его client_secret, подтвердим платёж через stripe.confirmPayment, разберёмся со статусами, 3-D Secure и ошибками карты и снова доверим итог вебхуку.

Checkout или своя форма

Checkout (урок 039) — готовая страница Stripe: проще и быстрее всего. Payment Element — форма в вашей странице: макет и текст вокруг поля ваши, а номер карты вводится в защищённом iframe Stripe и не попадает на сервер (SAQ A, урок 038). Цена свободы — код: кнопку, ошибки и возврат после 3-D Secure пишете вы.

СпособГде вводят картуКода
Checkout (hosted_page)страница Stripeменьше всего
Embedded Checkout (embedded_page)iframe Stripe в вашей страницемало
Payment Element + Checkout Sessions (ui_mode: 'elements')поля Stripe в вашем макетесредне
Payment Element + PaymentIntentполя Stripe в вашем макетебольше всего

💡 Честно о рекомендации. В документации (октябрь 2026) Stripe советует для большинства новых интеграций Checkout Sessions с Payment Element: ui_mode: 'elements', в React — CheckoutElementsProvider из @stripe/react-stripe-js/checkout и checkout.confirm. Кода меньше, налоги и скидки готовы. Мы разбираем PaymentIntent: он лежит и под Checkout, поэтому статусы, client_secret и вебхуки в обоих путях одинаковы.

PaymentIntent: «папка» одного платежа

PaymentIntent — объект Stripe, который ведёт один платёж от намерения до результата: сумма, валюта, способ оплаты, статус, ошибки. Аналогия: папка с делом. Вы заводите её, когда известна сумма, клиент кладёт в неё карту, Stripe записывает, что произошло. Не вышло — папка остаётся, и клиент пробует снова.

С папкой Stripe выдаёт client_secret — ключ именно от неё. С ним браузер подтвердит этот платёж, но не изменит сумму и не увидит чужие платежи. Это не секретный ключ sk_…, который не покидает сервер; но и client_secret видит только покупатель этого заказа: его не логируют и не кладут в URL.

Последовательность Payment Element: браузер просит PaymentIntent, сервер создаёт его и отдаёт client_secret, карта идёт из iframe прямо в Stripe, затем 3-D Secure, статус в браузере и вебхук на сервер
Последовательность Payment Element: браузер просит PaymentIntent, сервер создаёт его и отдаёт client_secret, карта идёт из iframe прямо в Stripe, затем 3-D Secure, статус в браузере и вебхук на сервер

Шаги 1–3: сервер создаёт PaymentIntent и возвращает браузеру только client_secret. Шаги 4–5: stripe.confirmPayment отправляет данные карты из iframe прямо в Stripe, при необходимости Stripe.js открывает окно 3-D Secure. Шаги 6–7: браузер узнаёт итог, а настоящее подтверждение приходит вебхуком.

Шаг 1. Сервер создаёт PaymentIntent

Добавим в Order поле для связи (npx prisma migrate dev --name add_payment_intent_id):

prisma
model Order {
  // id, sku, quantity, total, status, stripeSessionId: as before
  stripePaymentIntentId String? @unique
}

Маршрут POST /orders/:id/payment-intent берёт заказ из базы, создаёт PaymentIntent и возвращает clientSecret: