Payment Form Development with Payment Integration
A payment form is the point where a user becomes a customer or leaves forever. There's no room for redirects to third-party pages with unfamiliar design, unexplained errors, or fields that reset after failed attempt. The goal is to embed payment collection directly into site interface so user stays on page and doesn't lose context.
Embedded Form Architecture
Modern payment gateways offer two integration variants: redirect to gateway page and embedded form. The latter is preferable for most sites because it preserves visual context and increases trust.
Typical Stripe scheme:
// Initialize Stripe Elements
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');
// Handle submit
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);
}
});
For Russian market, YooKassa (former Yandex.Kassa) or CloudPayments is more common. CloudPayments provides own SDK for embedded form:
var widget = new cp.CloudPayments();
widget.charge(
{
publicId: 'pk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
description: 'Order #12345',
amount: 4990,
currency: 'RUB',
invoiceId: '12345',
email: '[email protected]',
skin: 'mini',
data: { orderId: '12345', userId: 42 },
},
function (options) {
// success callback
updateOrderStatus(options.invoiceId, 'paid');
},
function (reason, options) {
// fail callback
logPaymentError(reason, options);
}
);
Server-side: Payment Intent Creation
No amount logic should come from client. Browser amount from hidden field is untrustworthy — it can be replaced. Server creates payment intent with real amount from database:
// Laravel — create PaymentIntent via Stripe
use Stripe\StripeClient;
public function createPaymentIntent(Request $request): JsonResponse
{
$order = Order::findOrFail($request->order_id);
// Check order belongs to current user
abort_if($order->user_id !== auth()->id(), 403);
$stripe = new StripeClient(config('services.stripe.secret'));
$intent = $stripe->paymentIntents->create([
'amount' => $order->total_cents, // in kopecks/cents
'currency' => 'rub',
'metadata' => [
'order_id' => $order->id,
'user_id' => $order->user_id,
],
'automatic_payment_methods' => ['enabled' => true],
]);
return response()->json([
'client_secret' => $intent->client_secret,
]);
}
Client gets only client_secret — it contains no amount, cannot be used to change parameters.
Webhook and Payment Confirmation
Form on frontend reports success, but that's not guarantee. Money can hang, bank can decline after redirect. Only reliable source of truth is webhook from payment gateway.
// Stripe webhook handler
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->handleSuccess($event->data->object),
'payment_intent.payment_failed' => $this->handleFailed($event->data->object),
'charge.dispute.created' => $this->handleDispute($event->data->object),
default => null,
};
return response('OK', 200);
}
private function handleSuccess(\Stripe\PaymentIntent $intent): void
{
$order = Order::where('stripe_payment_intent', $intent->id)->firstOrFail();
$order->update(['status' => 'paid', 'paid_at' => now()]);
// Send receipt, initiate shipping, notify
dispatch(new SendReceiptJob($order));
dispatch(new InitiateShippingJob($order));
}
For YooKassa similar scheme via HMAC-signature:
$body = $request->getContent();
$key = config('services.yookassa.secret_key');
// YooKassa doesn't use signature for webhook — verify via API
$notification = new \YooKassa\Model\Notification\NotificationSucceeded(
json_decode($body, true)
);
$payment = $notification->getObject();
UX Details That Affect Conversion
Real-time validation. Card number should format into groups of 4 digits during input. Expiry date — auto-add /. If Luhn check fails — report immediately, don't wait for submit.
Save progress. If user filled email, name, address — and payment form crashed with error, all fields should remain. Clear only CVV (PCI DSS requirement).
Status indication. "Pay" button should show spinner during request and disable from re-clicking. Double payment is real problem.
Mobile keyboard. Card number field should open numeric keyboard (inputmode="numeric"), not letter. Small detail forgotten in half cases.
<input
type="text"
inputmode="numeric"
autocomplete="cc-number"
placeholder="0000 0000 0000 0000"
pattern="[0-9\s]{13,19}"
/>
Security and PCI DSS Compliance
Card data should never pass through your server — only through payment gateway's iframe or its JavaScript library. This is PCI DSS SAQ A level (simplest for merchant).
If card data touched your app even for millisecond — you automatically jump to SAQ D level with yearly audit, pentest, hundreds of mandatory requirements.
Content Security Policy for payment form pages:
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;
Multiple Payment Methods Support
Stripe Payment Element shows cards, Apple Pay, Google Pay, SEPA, Klarna and dozen more methods — automatically, depending on customer's country and browser.
CloudPayments supports cards, SBP (Fast Payment System), Tinkoff Pay. SBP especially useful: lower commission, high conversion on mobile, no card data entry needed.
To setup Apple Pay via CloudPayments requires domain verification — place file at /.well-known/apple-developer-merchantid-domain-association. This is Apple requirement.
Timeline and Stages
Typical integration for one gateway with test environment takes 3–5 business days. This includes: merchant account setup, server-side (payment intent creation, webhook), client-side form, test card testing, production switch.
Adding second gateway (e.g., for failover) — another 2–3 days for payment routing logic.
Fiscalization integration (54-ФЗ, receipt sending via OFD) — separate task, 2–4 days, depends if gateway has built-in support (YooKassa and CloudPayments do).







