Integrarea unui sistem de plată online este una dintre cele mai frecvente cerințe în proiectele web din România — și una dintre cele mai greșit abordate. Majoritatea tutorialelor de pe internet acoperă fie doar Stripe (ignorând realitatea pieței românești unde Netopia domină în multe sectoare), fie se opresc la „ai copiat SDK-ul, ești gata" fără să acopere webhook-urile, gestionarea erorilor sau securitatea. Acest ghid acoperă ambele procesoare, cu cod real în PHP și JavaScript, de la setup la prima tranzacție testată cu succes.
Înainte să începi, o clarificare importantă despre alegerea procesatorului: Stripe este disponibil în România din 2022, acceptă carduri internaționale (Visa, Mastercard, Amex), oferă cel mai bun DX (developer experience) din industrie și este ideal pentru SaaS, aplicații internaționale sau orice proiect unde experiența de plată contează. Netopia Payments (fostul MobilPay) este procesatorul românesc de referință, integrat cu băncile locale, acceptat de majoritatea firmelor românești deja înregistrate fiscal, și oferă avantaje clare pentru business-uri B2C locale. Mulți developeri aleg să integreze ambele și să ofere clientului opțiunea.
Stripe oferă SDK-uri oficiale pentru toate limbajele majore. Instalezi biblioteca PHP prin Composer:
composer require stripe/stripe-php
Dacă nu folosești Composer, poți descărca manual biblioteca, dar Composer este recomandat pentru că gestionează automat actualizările de securitate. Înainte să scrii orice cod, intri pe dashboard.stripe.com, creezi un cont sau te loghezi, și din secțiunea Developers → API Keys copiezi cele două chei: Publishable key (începe cu pk_test_ pentru test sau pk_live_ pentru producție) și Secret key (sk_test_ sau sk_live_). Nu expui niciodată Secret key în cod frontend sau în repository-uri publice.
Configurezi cheile într-un fișier de mediu (.env) separat de codul sursă:
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxx
STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxx
Și le încarci în PHP:
<?php
require_once 'vendor/autoload.php';
$stripeSecretKey = getenv('STRIPE_SECRET_KEY');
\Stripe\Stripe::setApiKey($stripeSecretKey);
?>
Fluxul modern Stripe se bazează pe Payment Intents — obiecte care reprezintă intenția de plată și gestionează automat autentificarea 3D Secure, reîncercările și stările intermediare. Evită abordarea mai veche cu Charges directe, care nu suportă 3DS (obligatoriu în Europa prin regulamentul PSD2).
Creezi un endpoint PHP care generează Payment Intent-ul și returnează client_secret către frontend:
<?php
// create-payment-intent.php
require_once 'vendor/autoload.php';
header('Content-Type: application/json');
\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));
$data = json_decode(file_get_contents('php://input'), true);
// Validare sumă (suma este în bani, nu în RON — 1000 = 10.00 RON)
$amount = intval($data['amount']);
if ($amount < 100) {
http_response_code(400);
echo json_encode(['error' => 'Suma minimă este 1 RON']);
exit;
}
try {
$paymentIntent = \Stripe\PaymentIntent::create([
'amount' => $amount, // în bani (cents/bani)
'currency' => 'ron', // RON pentru România
'automatic_payment_methods' => [
'enabled' => true,
],
'metadata' => [
'order_id' => $data['order_id'] ?? '',
'customer_id' => $data['customer_id'] ?? '',
],
]);
echo json_encode([
'clientSecret' => $paymentIntent->client_secret,
]);
} catch (\Stripe\Exception\ApiErrorException $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
?>
Pe partea de client, Stripe oferă Stripe Elements — componente UI prebuilt care gestionează securitatea datelor cardului direct pe serverele Stripe. Datele sensibile ale cardului nu trec niciodată prin serverul tău, eliminând necesitatea conformității PCI-DSS de nivel înalt.
<!-- index.html -->
<script src="https://js.stripe.com/v3/"></script>
<form id="payment-form">
<div id="payment-element"></div>
<button id="submit-btn" type="submit">
<span id="btn-text">Plătește acum</span>
<span id="spinner" style="display:none">Se procesează...</span>
</button>
<div id="payment-message" style="display:none"></div>
</form>
<script>
const stripe = Stripe('pk_test_xxxxxxxxxxxxxxxxxxxx');
let elements;
async function initialize() {
// Cere client_secret de la backend
const response = await fetch('/create-payment-intent.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
amount: 9900, // 99.00 RON
order_id: 'ORD-001',
customer_id: 'USR-123',
}),
});
const { clientSecret, error } = await response.json();
if (error) {
showMessage('Eroare: ' + error);
return;
}
// Inițializezi Elements cu clientSecret
elements = stripe.elements({ clientSecret });
// Creezi și montezi Payment Element
const paymentElement = elements.create('payment');
paymentElement.mount('#payment-element');
}
// Submitare formular
document.getElementById('payment-form')
.addEventListener('submit', async (e) => {
e.preventDefault();
setLoading(true);
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: 'https://siteultau.ro/confirmare-plata',
},
});
// Ajungi aici doar dacă apare o eroare imediată
if (error.type === 'card_error' || error.type === 'validation_error') {
showMessage(error.message);
} else {
showMessage('A apărut o eroare neașteptată. Te rugăm să încerci din nou.');
}
setLoading(false);
});
function showMessage(msg) {
const el = document.getElementById('payment-message');
el.style.display = 'block';
el.textContent = msg;
}
function setLoading(isLoading) {
document.getElementById('submit-btn').disabled = isLoading;
document.getElementById('spinner').style.display = isLoading ? 'inline' : 'none';
document.getElementById('btn-text').style.display = isLoading ? 'none' : 'inline';
}
initialize();
</script>
Cel mai frecvent greșit implementat aspect al integrării Stripe sunt webhook-urile. Mulți developeri marchează comanda ca plătită imediat după ce frontend-ul raportează succes — această abordare este vulnerabilă la fraud și la erori de rețea. Calea corectă este să asculți evenimentele Stripe pe un endpoint dedicat de webhook.
<?php
// webhook.php
require_once 'vendor/autoload.php';
\Stripe\Stripe::setApiKey(getenv('STRIPE_SECRET_KEY'));
$webhookSecret = getenv('STRIPE_WEBHOOK_SECRET');
$payload = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_STRIPE_SIGNATURE'] ?? '';
try {
$event = \Stripe\Webhook::constructEvent(
$payload,
$sigHeader,
$webhookSecret
);
} catch (\UnexpectedValueException $e) {
// Payload invalid
http_response_code(400);
exit;
} catch (\Stripe\Exception\SignatureVerificationException $e) {
// Semnătură invalidă — posibil atac
http_response_code(400);
exit;
}
// Procesezi evenimentul în funcție de tip
switch ($event->type) {
case 'payment_intent.succeeded':
$paymentIntent = $event->data->object;
$orderId = $paymentIntent->metadata->order_id;
// Marchezi comanda ca plătită în baza ta de date
marcheazaComandaPlata($orderId, $paymentIntent->id);
// Trimiți email de confirmare
trimiteEmailConfirmare($orderId);
break;
case 'payment_intent.payment_failed':
$paymentIntent = $event->data->object;
$orderId = $paymentIntent->metadata->order_id;
// Notifici clientul de eșec
marcheazaComandaEsuata($orderId);
break;
case 'charge.refunded':
// Procesezi rambursarea
$charge = $event->data->object;
$orderId = $charge->metadata->order_id ?? '';
proceseazaRambursare($orderId, $charge->amount_refunded);
break;
}
http_response_code(200);
echo json_encode(['received' => true]);
?>
Înregistrezi URL-ul de webhook în dashboard Stripe la Developers → Webhooks → Add endpoint. URL-ul trebuie să fie accesibil public (HTTPS). Pentru testare locală, folosești Stripe CLI cu comanda stripe listen --forward-to localhost:8888/webhook.php, care creează un tunel temporar și îți dă un webhook secret de test. Dacă lucrezi local și vrei să testezi fără să iei un domeniu real, ghidul despre MAMP Pro și Cloudflare Tunnel de pe blogul seotoolpro.ro îți arată cum expui localhost-ul pe internet cu un domeniu real.
Netopia Payments (netopia-payments.com) este procesatorul de plăți cu cea mai mare penetrare în piața românească. Pentru a-l integra, creezi un cont de merchant pe platforma lor, trimiți documentele de înregistrare ale firmei și primești credențialele de sandbox pentru testare: Signature (identificatorul merchant-ului) și perechea de chei RSA (public + private) pentru criptarea comunicației.
Netopia nu oferă un SDK oficial complet la fel ca Stripe, dar există biblioteca PHP mobilpay/php pe care o instalezi prin Composer:
composer require netopia/payment
Sau, pentru proiecte fără Composer, descarci manual biblioteca și o incluzi cu require. Pui cheile RSA în fișiere separate pe server, în afara folderului public:
/var/www/
/public_html/ ← accesibil web
/plata/
initiate.php
confirm.php
/private/ ← inaccesibil din browser
/netopia/
sandbox.xxxx.public.cer
sandbox.xxxx.private.key
Fluxul Netopia este diferit de Stripe: în loc să creezi o intenție de plată și să colectezi datele cardului pe site-ul tău, redirecționezi clientul pe pagina securizată Netopia, unde introduce datele cardului. Netopia trimite răspunsul înapoi la URL-ul tău de confirmare printr-un request server-to-server.
<?php
// initiate.php — inițiezi plata și redirecționezi spre Netopia
require_once 'vendor/autoload.php';
// Date comandă
$orderId = 'ORD-' . time();
$amount = 99.00; // în RON, nu în bani
$currency = 'RON';
$returnUrl = 'https://siteultau.ro/plata/return.php';
$confirmUrl = 'https://siteultau.ro/plata/confirm.php';
// Date client
$billingFirstName = $_POST['first_name'] ?? '';
$billingLastName = $_POST['last_name'] ?? '';
$billingEmail = $_POST['email'] ?? '';
// Configurare request Netopia
$payment = new \Netopia\Payment\Request\Card();
$payment->signature = 'XXXX-XXXX-XXXX-XXXX-XXXX'; // Signature merchant
$payment->orderId = $orderId;
$payment->returnUrl = $returnUrl;
$payment->confirmUrl = $confirmUrl;
$payment->type = \Netopia\Payment\Request\PaymentAbstract::PAYMENT_TYPE_CARD;
// Setezi suma
$payment->invoice = new \Netopia\Payment\Invoice();
$payment->invoice->currency = $currency;
$payment->invoice->amount = $amount;
$payment->invoice->tokenId = null;
$payment->invoice->details = 'Comanda ' . $orderId;
// Date facturare
$billing = new \Netopia\Payment\Address();
$billing->type = 'person';
$billing->firstName = $billingFirstName;
$billing->lastName = $billingLastName;
$billing->email = $billingEmail;
$payment->invoice->setBillingAddress($billing);
// Criptezi și generezi formularul de redirect
$payment->encrypt(
'/var/www/private/netopia/sandbox.xxxx.public.cer'
);
// URL sandbox Netopia
$netopiaUrl = 'https://sandboxsecure.mobilpay.ro';
// Pentru producție: https://secure.mobilpay.ro
?>
<!DOCTYPE html>
<html>
<head><title>Redirecționare...</title></head>
<body onload="document.forms[0].submit()">
<form action="<?= $netopiaUrl ?>" method="post">
<input type="hidden" name="data" value="<?= $payment->encryptedData ?>">
<input type="hidden" name="env_key" value="<?= $payment->encryptedKey ?>">
<noscript>
<button type="submit">Continuă spre plată</button>
</noscript>
</form>
</body>
</html>
Netopia trimite rezultatul plății la URL-ul tău de confirmare printr-un request POST cu datele criptate. Trebuie să decriptezi răspunsul, să verifici statusul și să răspunzi cu un XML corect — altfel Netopia va considera că nu ai primit confirmarea și va retrimite.
<?php
// confirm.php — primești și procesezi răspunsul Netopia
require_once 'vendor/autoload.php';
$privateKeyPath = '/var/www/private/netopia/sandbox.xxxx.private.key';
header('Content-Type: application/xml');
if (!isset($_POST['data']) || !isset($_POST['env_key'])) {
echo '<?xml version="1.0" encoding="utf-8"?>
<crc error_type="1" error_code="1000">Cerere invalidă</crc>';
exit;
}
try {
$payment = new \Netopia\Payment\Request\Card();
$payment->decrypt(
$_POST['env_key'],
$_POST['data'],
$privateKeyPath
);
$errorCode = $payment->objPmNotify->errorCode;
$errorMessage = $payment->objPmNotify->errorMessage;
$action = $payment->objPmNotify->action;
$orderId = $payment->orderId;
$amount = $payment->invoice->amount;
switch ($payment->objPmNotify->action) {
case 'confirmed':
// Plata confirmată — marchezi comanda în DB
if ($errorCode == 0) {
marcheazaComandaPlata($orderId, $amount);
trimiteEmailConfirmare($orderId);
echo '<?xml version="1.0" encoding="utf-8"?>
<crc>' . $payment->objPmNotify->getCrc() . '</crc>';
}
break;
case 'confirmed_pending':
// În așteptarea confirmării manuale
marcheazaComandaPending($orderId);
echo '<?xml version="1.0" encoding="utf-8"?>
<crc>' . $payment->objPmNotify->getCrc() . '</crc>';
break;
case 'paid_pending':
// Plătit, în verificare antifraudă
marcheazaComandaPending($orderId);
echo '<?xml version="1.0" encoding="utf-8"?>
<crc>' . $payment->objPmNotify->getCrc() . '</crc>';
break;
case 'canceled':
case 'rejected':
// Plată refuzată sau anulată
marcheazaComandaEsuata($orderId, $errorMessage);
echo '<?xml version="1.0" encoding="utf-8"?>
<crc>' . $payment->objPmNotify->getCrc() . '</crc>';
break;
case 'credit':
// Rambursare procesată
proceseazaRambursare($orderId, $amount);
echo '<?xml version="1.0" encoding="utf-8"?>
<crc>' . $payment->objPmNotify->getCrc() . '</crc>';
break;
default:
echo '<?xml version="1.0" encoding="utf-8"?>
<crc error_type="2" error_code="0">Acțiune necunoscută</crc>';
}
} catch (Exception $e) {
error_log('Netopia error: ' . $e->getMessage());
echo '<?xml version="1.0" encoding="utf-8"?>
<crc error_type="1" error_code="1001">Eroare internă</crc>';
}
?>
Un detaliu critic: URL-ul de confirmare Netopia trebuie să fie accesibil public și să răspundă cu XML valid în mai puțin de 10 secunde. Dacă serverul tău este lent sau URL-ul returnează alt tip de conținut, Netopia va retrimite notificarea de mai multe ori, ceea ce poate duce la înregistrarea duplicată a plăților. Testezi disponibilitatea URL-ului tău cu Online Ping Website Tool și verifici că răspunde corect cu Server Status Checker de pe seotoolpro.ro.
Ambii procesatori oferă medii de sandbox cu carduri de test care simulează scenarii diferite, fără tranzacții reale.
Pentru Stripe, cardurile de test standard sunt:
/* Plată reușită */
Număr: 4242 4242 4242 4242
Data: orice dată viitoare (ex: 12/28)
CVC: orice 3 cifre (ex: 123)
ZIP: orice (ex: 12345)
/* Plată eșuată — card refuzat */
Număr: 4000 0000 0000 0002
/* Necesită autentificare 3D Secure */
Număr: 4000 0025 0000 3155
/* Card expirat */
Număr: 4000 0000 0000 0069
/* Fonduri insuficiente */
Număr: 4000 0000 0000 9995
Pentru Netopia sandbox, folosești datele de card furnizate în documentația contului tău de merchant sandbox — Netopia are carduri de test diferite pentru confirmare, respingere și pending.
Verifică întotdeauna că ai testat cel puțin trei scenarii înainte de lansare: plată reușită (marchezi comanda corect în DB), plată eșuată (nu marchezi nimic, afișezi eroare utilă clientului), și plată cu 3DS (Stripe gestionează automat flow-ul, dar trebuie să confirmi că redirect-ul funcționează). Poți folosi SSL Checker de pe seotoolpro.ro pentru a verifica că certificatul HTTPS al domeniului tău este valid — ambii procesatori refuză să funcționeze pe HTTP în producție.
Securitatea unui sistem de plată nu este opțională și nu este o listă de bune practici — este obligatorie legal și tehnic. Câteva reguli absolute pe care trebuie să le respecți indiferent de procesatorul ales:
Nu stoca niciodată datele complete ale cardului pe serverul tău. Nici temporar, nici în logs. Dacă ai nevoie de plăți recurente (abonamente), folosești tokenizarea oferită de procesator — Stripe și Netopia stochează datele cardului la ei și îți dau un token pe care îl poți refolosi pentru charge-uri viitoare fără să atingi datele sensibile.
Nu expui Secret Key-ul în cod frontend, în repository-uri Git publice sau în fișiere accesibile web. Dacă ai expus accidental un Secret Key, îl dezactivezi imediat din dashboard-ul procesatorului și generezi unul nou. Verifici dacă fișierele tale de configurare sunt accesibile public cu Get Source Code of Webpage.
Validezi suma pe server, nu doar pe client. Un utilizator malițios poate modifica suma trimisă din frontend înainte ca request-ul să ajungă la serverul tău. Suma corectă trebuie întotdeauna citită din baza ta de date, nu din input-ul clientului:
<?php
// GREȘIT — suma vine de la client, poate fi manipulată
$amount = $_POST['amount'];
// CORECT — suma vine din baza de date, pe baza order_id
$order = getOrderFromDB($_POST['order_id']);
$amount = $order['total_amount'];
?>
Verifici semnătura webhook-urilor înainte să procesezi orice eveniment, exact cum am arătat în exemplul de mai sus. Un endpoint de webhook fără verificare de semnătură poate fi exploatat de oricine știe URL-ul tău pentru a trimite evenimente false și a marca comenzi ca plătite fără ca tranzacția să existe.
Implementezi idempotență în procesarea webhook-urilor. Ambii procesatori pot retrimite același eveniment de mai multe ori (în caz de timeout sau eroare de rețea). Baza ta de date trebuie să verifice dacă un payment_intent_id sau un order_id a mai fost procesat înainte să marcheze din nou comanda ca plătită:
<?php
function marcheazaComandaPlata($orderId, $paymentId) {
// Verifici mai întâi dacă nu a mai fost procesată
$stmt = $pdo->prepare(
'SELECT id FROM orders WHERE id = ? AND status = "paid"'
);
$stmt->execute([$orderId]);
if ($stmt->rowCount() > 0) {
// Comanda e deja marcată — ieși fără să faci nimic
return;
}
// Actualizezi statusul
$stmt = $pdo->prepare(
'UPDATE orders SET status = "paid", payment_id = ?,
paid_at = NOW() WHERE id = ?'
);
$stmt->execute([$paymentId, $orderId]);
}
?>
Criteriu | Stripe | Netopia
----------------------|---------------------------|---------------------------
Comision tranzacție | 1.5% + 0.25€ (carduri EU) | ~1.6-2.4% (negociabil)
Carduri acceptate | Visa, MC, Amex, Apple Pay | Visa, Mastercard
Timp decontare | 7 zile (standard) | Variabil, negociabil
Developer Experience | Excelent, SDK complet | Mediu, documentație limitată
Suport 3DS2 | Da, automat | Da
Disponibil sandbox | Da, instant | Da, necesită aprobare cont
Integrare cu firme RO | Funcționează | Nativă, preferat de bănci RO
Facturare în RON | Da | Da
Rambursare API | Da, complet | Da, parțial
Recomandarea practică pentru 2026: dacă construiești un SaaS sau un produs cu audiență internațională, mergi cu Stripe — experiența de dezvoltare, documentația și fiabilitatea sunt de neegalat. Dacă construiești un magazin online sau o platformă B2C pentru piața românească unde clienții sunt obișnuiți cu Netopia, integrezi Netopia ca opțiune primară sau unică. Dacă bugetul și timpul permit, integrezi ambele și lași clientul să aleagă.
Integrarea unui sistem de plată online nu este complicată dacă urmezi structura corectă: backend care creează intenția de plată, frontend care colectează datele cardului prin SDK-ul procesatorului, și un endpoint de webhook care confirmă tranzacția pe server înainte să marchezi comanda ca plătită. Greșelile clasice — suma din client, lipsa verificării semnăturii, lipsa idempotentei — sunt evitabile dacă înțelegi fluxul complet de la primul request până la confirmarea finală.
Codul din acest articol este un punct de pornire real, nu pseudocod — îl adaptezi cu credențialele tale, cu funcțiile de bază de date proprii și cu logica de business specifică proiectului tău. Testezi pe sandbox până când toate scenariile (succes, eșec, 3DS, rambursare) funcționează corect, verifici securitatea cu SSL Checker și Password Strength Checker de pe seotoolpro.ro, și abia apoi treci în producție.
Pentru mai multă inspirație despre dezvoltare web și integrări API, citește și articolele noastre despre PHP 8 — funcții și best practices și securitate WordPress — ghid complet.
Lasă un comentariu