Pagamenti con Stripe lato client

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

  1. Carica Stripe.js dal CDN ufficiale.
  2. Ottiene dal backend un client_secret (creato dal server).
  3. Inizializza Stripe con la publishable key.
  4. Crea e monta il Payment Element (o altri Elements specifici come Card Element).
  5. Alla conferma dell’utente, chiama stripe.confirmPayment (o metodi analoghi) passando Elements e return_url.
  6. 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

  1. Carica Stripe.js da CDN ufficiale.
  2. Ottieni dal server un clientSecret per il PaymentIntent/SetupIntent.
  3. Inizializza Stripe con publishable key.
  4. Crea elements con clientSecret e monta Payment Element.
  5. Alla submit: elements.submit() e poi stripe.confirmPayment() con return_url.
  6. Gestisci la pagina di ritorno con retrievePaymentIntent.
  7. 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.

Torna su