Форма оплати — точка, де користувач перетворюється на покупця або йде назавжди. Ми створюємо вбудовану платіжну форму, яка зберігає контекст сайту і не змушує клієнта переходити на сторонні сторінки. Тут не повинно бути редиректів з чужим дизайном, незрозумілих помилок без пояснень або полів, яки скидаються після невдалої спроби. Наше завдання — вбудувати прийом платежів прямо в інтерфейс сайту так, щоб користувач залишався на сторінці та довіряв процесу.
За нашими даними, вбудована форма збільшує конверсію на 15-20% порівняно з редиректом, а середній чек зростає на 27%. При цьому кількість відмов знижується вдвічі. Як досягти таких показників? Розберемо технічну реалізацію.
Як працює вбудована форма?
Сучасні платіжні шлюзи надають два варіанти інтеграції: редирект на сторінку шлюзу та вбудований віджет (embedded form). Другий варіант кращий — він зберігає візуальний контекст і підвищує довіру. Розглянемо приклади коду для Stripe Stripe API Docs та CloudPayments. Нижче наведені типові конфігурації:
// Stripe Payment Element
const stripe = Stripe('pk_live_...');
const elements = stripe.elements({
appearance: {
theme: 'flat',
variables: {
colorPrimary: '#0f172a',
fontFamily: 'Inter, sans-serif',
},
},
});
const paymentElement = elements.create('payment', {
layout: { type: 'tabs', defaultCollapsed: false },
});
paymentElement.mount('#payment-element');
const form = document.getElementById('payment-form');
form.addEventListener('submit', async (e) => {
e.preventDefault();
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: 'https://example.com/order/complete',
},
});
if (error) showError(error.message);
});
// CloudPayments widget
var widget = new cp.CloudPayments();
widget.charge({
publicId: 'pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
description: 'Замовлення #12345',
amount: 4990,
currency: 'UAH',
invoiceId: '12345',
email: '[email protected]',
skin: 'mini',
data: { orderId: '12345', userId: 42 },
},
function (options) { updateOrderStatus(options.invoiceId, 'paid'); },
function (reason, options) { logPaymentError(reason, options); }
);
Як сервер створює платіжне намір?
Жодна логіка суми не повинна йти з боку клієнта. Браузер не довіряє сумі з hidden-поля — її підміняють. Сервер створює платіжне намір з реальною сумою з бази:
// Laravel — створення PaymentIntent через Stripe
use Stripe\StripeClient;
public function createPaymentIntent(Request $request): JsonResponse
{
$order = Order::findOrFail($request->order_id);
abort_if($order->user_id !== auth()->id(), 403);
$stripe = new StripeClient(config('services.stripe.secret'));
$intent = $stripe->paymentIntents->create([
'amount' => $order->total_cents,
'currency' => 'uah',
'metadata' => [
'order_id' => $order->id,
'user_id' => $order->user_id,
],
'automatic_payment_methods' => ['enabled' => true],
]);
return response()->json([
'client_secret' => $intent->client_secret,
]);
}
Клієнт отримує тільки client_secret — він не містить суми, не може бути використаний для зміни параметрів.
Чому webhook — єдине джерело істини?
Форма на фронтенді повідомляє про успіх, але це не гарантія. Гроші можуть зависнути, банк може відхилити транзакцію вже після редиректу. Єдине надійне джерело — webhook від платіжного шлюзу:
// Обробник webhook Stripe
public function handleWebhook(Request $request): Response
{
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
try {
$event = \Stripe\Webhook::constructEvent(
$payload, $sigHeader, config('services.stripe.webhook_secret')
);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
return response('Invalid signature', 400);
}
match ($event->type) {
'payment_intent.succeeded' => $this->handlePaymentSucceeded($event->data->object),
'payment_intent.payment_failed' => $this->handlePaymentFailed($event->data->object),
'charge.dispute.created' => $this->handleDispute($event->data->object),
default => null,
};
return response('OK', 200);
}
private function handlePaymentSucceeded(\Stripe\PaymentIntent $intent): void
{
$order = Order::where('stripe_payment_intent', $intent->id)->firstOrFail();
$order->update(['status' => 'paid', 'paid_at' => now()]);
dispatch(new SendReceiptJob($order));
dispatch(new InitiateShippingJob($order));
}
Як забезпечити безпеку даних картки?
Дані картки ніколи не повинні проходити через ваш сервер — тільки через iframe платіжного шлюзу або його JavaScript-бібліотеку. Це відповідає рівню PCI DSS SAQ A — найпростішому для мерчанта. Якщо дані картки хоча б на мілісекунду опинилися у вашому додатку, ви переходите на рівень SAQ D з щорічним аудитом і сотнями обов'язкових вимог.
Рекомендована Content Security Policy для сторінок з формою оплати:
Content-Security-Policy:
default-src 'self';
script-src 'self' https://js.stripe.com https://widget.cloudpayments.ru;
frame-src https://js.stripe.com https://widget.cloudpayments.ru;
connect-src 'self' https://api.stripe.com;
Порівняння підходів: редирект vs вбудована форма
| Параметр | Редирект | Вбудована форма |
|---|---|---|
| Конверсія | Базова | Вище на 15-20% |
| Утримання контексту | Ні | Так |
| Час завантаження | Додатковий запит | Миттєво |
| Довіра користувача | Середня | Висока (брендування) |
Порівняння платіжних шлюзів
| Шлюз | Методи оплати | PCI DSS рівень | Фіскалізація 54-ФЗ | Особливості |
|---|---|---|---|---|
| Stripe | Карти, Apple Pay, Google Pay, SEPA, Klarna | SAQ A | Ні (через сторонні сервіси) | Глобальне охоплення, 135+ валют |
| CloudPayments | Карти, СБП, Tinkoff Pay | SAQ A | Вбудована | СБП з низькою комісією, міні-форма |
| ЮKassa | Карти, СБП, Apple Pay, Google Pay | SAQ A | Вбудована | Проста інтеграція, російський ринок |
Stripe виграє за кількістю методів оплати, але для України CloudPayments або ЮKassa кращі через нативну підтримку СБП та фіскалізації.
UX-деталі, які впливають на конверсію
Валідація в реальному часі
Номер картки повинен форматуватися групами по 4 цифри прямо під час введення. Термін дії — автоматично додавати /. Якщо Luhn-перевірка не проходить — повідомляйте одразу, не чекайте сабміту.
Збереження прогресу
Якщо користувач заповнив email, ім'я, адресу — і форма оплати впала з помилкою, всі поля повинні залишитися. Очищайте тільки CVV (вимога PCI DSS).
Індикація стану
Кнопка «Оплатити» повинна показувати спіннер під час запиту та блокуватися від повторного натискання. Подвійна оплата — реальна проблема.
Мобільна клавіатура
Поле номера картки має відкривати числову клавіатуру (inputmode="numeric"), а не літерну. Дрібниця, яку забувають у половині випадків.
<input
type="text"
inputmode="numeric"
autocomplete="cc-number"
placeholder="0000 0000 0000 0000"
pattern="[0-9\s]{13,19}"
/>
Підтримка кількох методів оплати
Stripe Payment Element з коробки показує карти, Apple Pay, Google Pay, SEPA, Klarna — автоматично, залежно від країни користувача та браузера. CloudPayments підтримує карти, СБП, Tinkoff Pay. СБП особливо корисний: менша комісія, висока конверсія на мобільних, не потрібно вводити дані картки.
Для налаштування Apple Pay через CloudPayments потрібна верифікація домену — розмістити файл на /.well-known/apple-developer-merchantid-domain-association. Це умова Apple.
Що входить в інтеграцію
- Налаштування акаунту мерчанта
- Серверна реалізація: створення платіжного наміру, обробка webhook
- Клієнтська форма з валідацією, адаптивною версткою та індикацією стану
- Тестування на тестових картках
- Інструкція з експлуатації та підтримка при запуску
Терміни та етапи
Типова інтеграція для одного шлюзу займає 3–5 робочих днів. Ми працюємо ітеративно: аналітика → проєктування → реалізація → тестування → деплой. Додавання другого шлюзу (наприклад, для резервування) — ще 2–3 дні. Інтеграція фіскалізації — окреме завдання, 2–4 дні, якщо шлюз її підтримує.
Замовте розробку форми з оплатою під ключ — ми реалізуємо інтеграцію в стислі терміни. Отримайте консультацію щодо вашого проєкту — напишіть нам.







