Integrare pagamenti con Stripe in una web app significa quasi sempre far collaborare due componenti: il client (browser o app mobile) e un backend che crea e gestisce oggetti sensibili come PaymentIntent e SetupIntent. “Lato client” non significa “solo front-end”: significa soprattutto conoscere cosa può e cosa non deve fare il browser, quali librerie usare e come gestire correttamente flussi come SCA/3DS e wallet (Apple Pay/Google Pay).
In questo articolo trovi:
- Che cosa avviene sul client in un’integrazione moderna con Stripe
- Stripe.js, Elements e Payment Element: ruoli, inizializzazione e rendering
- Conferma del pagamento e gestione di 3D Secure e redirect
- Gestione errori, stati UI, idempotenza e sicurezza
- Esempi completi di codice (HTML e JavaScript)
Prerequisiti e concetti chiave
Chiavi pubbliche e “client secret”
Nel browser si usa solo la publishable key di Stripe (es. pk_live_... o pk_test_...). Tutto ciò che richiede privilegi (creazione di PaymentIntent/SetupIntent, accesso a dati di pagamento, calcoli di importo, applicazione sconti, ecc.) resta sul server.
Il backend crea un PaymentIntent per incassare (pagamento “one-shot”) oppure un SetupIntent per salvare un metodo di pagamento per addebiti futuri. Il backend restituisce al client un client_secret, che il browser usa per completare (confermare) l’operazione tramite Stripe.js.
Perché Stripe Elements e Payment Element
Stripe fornisce componenti UI pre-costruiti e conformi ai requisiti PCI chiamati Elements. L’approccio consigliato oggi è usare il Payment Element, che gestisce più metodi di pagamento con una sola UI e si integra bene con SCA/3DS e wallet.
Architettura minima: che cosa fa il client
- Carica Stripe.js dal CDN ufficiale.
- Ottiene dal backend un client_secret (creato dal server).
- Inizializza Stripe con la publishable key.
- Crea e monta il Payment Element (o altri Elements specifici come Card Element).
- Alla conferma dell’utente, chiama
stripe.confirmPayment(o metodi analoghi) passando Elements ereturn_url. - Gestisce l’esito: successo immediato, richiesta autenticazione (3DS), redirect, errori di validazione o pagamento rifiutato.
Nota importante: il client non dovrebbe mai fidarsi dei propri calcoli di prezzo. L’importo e la valuta devono essere calcolati e fissati sul server, altrimenti un utente potrebbe manipolare il checkout.
Includere Stripe.js e inizializzare Stripe
Stripe.js va caricato da https://js.stripe.com/v3/. L’inizializzazione avviene con la publishable key e, in genere, con un’istanza di Elements creata usando il clientSecret di un PaymentIntent.
<!-- Carica Stripe.js -->
<script src="https://js.stripe.com/v3/"></script>
<form id="payment-form">
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required>
<label for="payment-element">Pagamento</label>
<span id="payment-element"></span>
<button id="submit" type="submit">Paga</button>
<p id="message" aria-live="polite"></p>
</form>
Nell’HTML sopra uso <span> come contenitore di mount per rispettare il vincolo di non usare div. Stripe Elements può montare su vari elementi del DOM; in molti casi un span è sufficiente.
Esempio moderno con Payment Element
Questo esempio presuppone che tu abbia un endpoint backend (es. /create-payment-intent) che ritorna JSON con clientSecret. Lato client:
(() => {
// 1) Configurazione
const publishableKey = "pk_test_xxxxxxxxxxxxxxxxx";
const form = document.getElementById("payment-form");
const submitButton = document.getElementById("submit");
const messageEl = document.getElementById("message");
let stripe;
let elements;
function setLoading(isLoading) {
submitButton.disabled = isLoading;
submitButton.textContent = isLoading ? "Elaborazione..." : "Paga";
}
function showMessage(text) {
messageEl.textContent = text || "";
}
async function createPaymentIntent(email) {
const res = await fetch("/create-payment-intent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email })
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error("Impossibile creare PaymentIntent. " + text);
}
return res.json(); // atteso: { clientSecret: "pi_..._secret_..." }
}
async function init() {
stripe = Stripe(publishableKey);
// Ottieni clientSecret dal server
const email = document.getElementById("email").value || "";
const { clientSecret } = await createPaymentIntent(email);
// Crea Elements collegato al PaymentIntent tramite clientSecret
elements = stripe.elements({
clientSecret,
// Opzionale: gestione locale/tema
appearance: {
theme: "stripe"
}
});
// Crea il Payment Element
const paymentElement = elements.create("payment");
// Monta senza usare <div>
paymentElement.mount("#payment-element");
}
async function handleSubmit(e) {
e.preventDefault();
setLoading(true);
showMessage("");
try {
// Valida campi dell’elemento (es. dati incompleti)
const { error: submitError } = await elements.submit();
if (submitError) {
showMessage(submitError.message);
setLoading(false);
return;
}
// Conferma il pagamento.
// Se Stripe richiede un redirect (es. 3DS o metodi redirect-based),
// l’utente verrà portato a return_url.
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: window.location.origin + "/checkout/return",
payment_method_data: {
billing_details: {
email: document.getElementById("email").value
}
}
}
});
// Se non c’è redirect, eventuali errori arrivano qui
if (error) {
showMessage(error.message || "Pagamento non riuscito.");
}
} catch (err) {
showMessage(err instanceof Error ? err.message : "Errore inatteso.");
} finally {
setLoading(false);
}
}
form.addEventListener("submit", handleSubmit);
// Inizializza quando la pagina è pronta
window.addEventListener("DOMContentLoaded", async () => {
try {
await init();
} catch (err) {
showMessage(err instanceof Error ? err.message : "Errore inizializzazione.");
setLoading(false);
}
});
})();
Gestire la pagina di ritorno dopo un redirect
Quando Stripe effettua un redirect (molto comune con 3D Secure o metodi come iDEAL, Bancontact, ecc.), l’utente torna su return_url con parametri in query. In quella pagina conviene recuperare lo stato del PaymentIntent via Stripe.js usando il payment_intent_client_secret presente nella URL.
(() => {
const publishableKey = "pk_test_xxxxxxxxxxxxxxxxx";
const stripe = Stripe(publishableKey);
async function checkStatus() {
const params = new URLSearchParams(window.location.search);
const clientSecret = params.get("payment_intent_client_secret");
const messageEl = document.getElementById("message");
if (!clientSecret) {
messageEl.textContent = "Nessun pagamento da verificare.";
return;
}
const { paymentIntent } = await stripe.retrievePaymentIntent(clientSecret);
switch (paymentIntent.status) {
case "succeeded":
messageEl.textContent = "Pagamento completato.";
break;
case "processing":
messageEl.textContent = "Pagamento in elaborazione.";
break;
case "requires_payment_method":
messageEl.textContent = "Pagamento non riuscito. Riprova con un altro metodo.";
break;
default:
messageEl.textContent = "Stato pagamento: " + paymentIntent.status;
break;
}
}
window.addEventListener("DOMContentLoaded", checkStatus);
})();
In un flusso robusto, oltre alla UI client, conviene sempre validare lo stato del pagamento sul server tramite webhook (ad esempio evento payment_intent.succeeded) e aggiornare l’ordine lato backend. Il client è utile per l’esperienza utente, ma non deve essere l’unica fonte di verità.
Quando usare Card Element e confirmCardPayment
Payment Element è la scelta preferibile perché abilita più metodi e gestisce molti casi automaticamente. Tuttavia, in progetti legacy o in UI molto personalizzate potresti usare Card Element e confermare con stripe.confirmCardPayment (o stripe.confirmPayment con parametri diversi). Il concetto resta identico: il server crea un PaymentIntent e il client lo conferma con il client secret.
async function payWithCardElement({ publishableKey, clientSecret }) {
const stripe = Stripe(publishableKey);
const elements = stripe.elements();
const card = elements.create("card");
card.mount("#card-element"); // anche qui puoi montare su uno <span> o altro contenitore
const { error, paymentIntent } = await stripe.confirmCardPayment(clientSecret, {
payment_method: {
card,
billing_details: {
name: "Nome Cognome"
}
}
});
if (error) {
throw error;
}
return paymentIntent;
}
Pagamenti “salva carta” con SetupIntent (client-side)
Se vuoi salvare un metodo di pagamento per addebiti futuri (abbonamenti, pay-per-use, depositi, ecc.), il pattern tipico è:
- il backend crea un SetupIntent e restituisce
client_secret - il client raccoglie i dati e conferma il SetupIntent
- il backend associa il metodo di pagamento al cliente e lo usa per addebiti futuri
async function confirmSetup({ publishableKey, clientSecret, elements }) {
const stripe = Stripe(publishableKey);
const { error } = await stripe.confirmSetup({
elements,
confirmParams: {
return_url: window.location.origin + "/billing/return"
}
});
if (error) {
throw error;
}
}
Wallet e metodi alternativi: Apple Pay, Google Pay e affini
Con Payment Element, spesso l’abilitazione dei wallet dipende da configurazioni dell’account Stripe, dal dominio verificato e dalle capability del browser/dispositivo. Dal lato client, l’integrazione resta sostanzialmente la stessa: monti Payment Element e confermi il pagamento; Stripe decide quali opzioni mostrare e come gestire i passaggi necessari.
Se invece vuoi un pulsante dedicato (es. Payment Request Button), Stripe mette a disposizione componenti specifici. In quel caso il client deve anche verificare la disponibilità del wallet prima di mostrarlo (es. paymentRequest.canMakePayment()).
Gestione degli errori e stati del form
Alcune regole pratiche lato client:
- Disabilita il pulsante durante le chiamate di rete e la conferma del pagamento per evitare doppi submit.
- Mostra messaggi accessibili (es. con
aria-live) e non affidarti solo al colore. - Gestisci errori distinti:
- errori di validazione (campi incompleti)
- errori di rete (backend non raggiungibile)
- errori di pagamento (carta rifiutata, autenticazione fallita, ecc.)
- Non mostrare dettagli tecnici sensibili in produzione; logga in modo sicuro sul server.
Sicurezza: cosa non fare mai nel browser
- Non creare PaymentIntent/SetupIntent dal client usando la secret key.
- Non calcolare importi finali sul client come fonte di verità (il client può essere manipolato).
- Non salvare in chiaro dati di pagamento o informazioni sensibili in localStorage.
- Non considerare “pagato” un ordine solo perché il client ha mostrato “successo”: usa webhook e riconciliazione server-side.
Pattern consigliato: backend come autorità, client come UX
Lato client, il tuo obiettivo è offrire un checkout affidabile e fluido. Lato server, il tuo obiettivo è garantire coerenza (importi, stock, sconti, antifrode) e finalizzare l’ordine solo quando Stripe conferma l’esito tramite eventi. Questa separazione riduce frodi e bug da stati incoerenti.
Checklist operativa
- Carica Stripe.js da CDN ufficiale.
- Ottieni dal server un
clientSecretper il PaymentIntent/SetupIntent. - Inizializza Stripe con publishable key.
- Crea
elementsconclientSecrete monta Payment Element. - Alla submit:
elements.submit()e poistripe.confirmPayment()conreturn_url. - Gestisci la pagina di ritorno con
retrievePaymentIntent. - Completa l’ordine sul server con webhook e logica business.
Appendice: esempio di risposta JSON attesa dal backend
{
"clientSecret": "pi_3Nxxxxxxxxxxxxxxxx_secret_xxxxxxxxxxxxxxxxx"
}
Con queste basi, puoi costruire checkout embedded moderni, gestire SCA/3DS senza reinventare la ruota e mantenere un perimetro di sicurezza corretto: il browser si occupa della raccolta e conferma del pagamento, mentre il server resta la fonte di verità per importi e finalizzazione degli ordini.