Pagamenti con Stripe in Node.js

Stripe è una delle soluzioni più diffuse per accettare pagamenti online. In Node.js puoi integrare Stripe in due modi principali: Checkout (pagina di pagamento ospitata da Stripe, più veloce e sicura da implementare) oppure Payment Intents (flusso personalizzato, massima flessibilità). In questa guida vedrai entrambi gli approcci, con particolare attenzione a sicurezza, webhook, test e buone pratiche operative.

Prerequisiti e concetti chiave

  • Account Stripe e chiavi API (test e live).
  • Node.js 18+ e un server HTTP (Express è l'esempio più comune).
  • Conoscenza di base di richieste HTTP e variabili d'ambiente.

Terminologia essenziale:

  • PaymentIntent: oggetto che rappresenta un tentativo di pagamento e il suo stato (richiede azione, riuscito, fallito).
  • Checkout Session: oggetto che crea una pagina di pagamento Stripe pronta all'uso.
  • Webhook: chiamata server-to-server con cui Stripe notifica eventi (pagamento riuscito, refund, chargeback, ecc.).
  • Idempotency: tecnica per prevenire doppie creazioni (ad esempio, doppio click su "Paga").

Installazione e configurazione sicura

Installa le dipendenze principali. Per semplicità useremo Express e la libreria ufficiale Stripe per Node:

npm init -y
npm install express stripe dotenv
npm install --save-dev nodemon

Crea un file .env (da non committare) e inserisci le chiavi:

STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
BASE_URL=http://localhost:4242

Note importanti:

  • Non esporre mai STRIPE_SECRET_KEY nel frontend.
  • Usa una chiave pubblicabile (pk_...) solo nel client quando necessario (ad esempio Stripe.js per confermare un PaymentIntent).
  • In produzione, gestisci i segreti con un secret manager (ad esempio AWS Secrets Manager, GCP Secret Manager, Vault) o variabili d'ambiente del tuo provider.

Server di esempio con Express

Ecco uno scheletro di server. Gestiremo due flussi: Checkout e Payment Intents, più i webhook.

import express from "express";
import dotenv from "dotenv";
import Stripe from "stripe";

dotenv.config();

const app = express();
const port = process.env.PORT || 4242;

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
  apiVersion: "2024-06-20",
});

// Per endpoint normali JSON
app.use(express.json());

// Endpoint healthcheck
app.get("/health", (_req, res) => {
  res.json({ ok: true });
});

app.listen(port, () => {
  console.log(`Server in ascolto su http://localhost:${port}`);
});

Importante: l'endpoint webhook richiede il corpo "raw" per verificare la firma. Quindi useremo un middleware diverso solo per quel percorso, evitando di applicare globalmente express.json() a quel route.

Approccio 1: Stripe Checkout (consigliato per iniziare)

Checkout ti consente di avviare un pagamento in pochi minuti con una pagina ospitata. È robusto, gestisce in modo affidabile SCA e 3D Secure dove richiesti, e riduce il rischio di errori nel frontend.

Creare una Checkout Session

In questo esempio creiamo una sessione in modalità payment con un singolo articolo. In un caso reale, calcola importi e prodotti lato server, usando prezzi dal database o da Stripe.

app.post("/create-checkout-session", async (req, res) => {
  try {
    // Esempio: importo e descrizione decisi lato server (non fidarti del client)
    const { quantity = 1 } = req.body ?? {};
    const safeQuantity = Math.max(1, Math.min(10, Number(quantity) || 1));

    const session = await stripe.checkout.sessions.create({
      mode: "payment",
      success_url: `${process.env.BASE_URL}/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${process.env.BASE_URL}/cancel`,
      line_items: [
        {
          quantity: safeQuantity,
          price_data: {
            currency: "eur",
            unit_amount: 1999, // 19,99 EUR in centesimi
            product_data: {
              name: "Abbonamento mensile (esempio)",
              description: "Esempio di prodotto per dimostrazione",
            },
          },
        },
      ],
      // Facoltativo ma utile: collega un utente interno
      metadata: {
        internalUserId: "user_123",
      },
    });

    res.json({ url: session.url });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: "Impossibile creare la sessione di pagamento." });
  }
});

Dal client, reindirizzi l'utente all'URL restituito (ad esempio con window.location.href = url).

Confermare il pagamento in modo affidabile

Non considerare "pagato" un ordine solo perché l'utente è finito su success_url. La conferma affidabile avviene tramite webhook: è Stripe a notificare al tuo server che il pagamento è andato a buon fine.

Approccio 2: Payment Intents (flusso personalizzato)

Se ti serve una UI totalmente custom (ad esempio un checkout integrato nel tuo sito con elementi Stripe), usa Payment Intents. Il pattern più comune è:

  1. Il server crea un PaymentIntent con importo e valuta calcolati in modo sicuro.
  2. Il client usa Stripe.js per confermare il pagamento con il client_secret.
  3. Il server riceve l'evento webhook e aggiorna lo stato dell'ordine.

Creare un PaymentIntent lato server

app.post("/create-payment-intent", async (req, res) => {
  try {
    // Nel mondo reale: calcola amount da carrello/ordine nel DB
    const { orderId } = req.body ?? {};
    if (!orderId) {
      return res.status(400).json({ error: "orderId obbligatorio" });
    }

    const amount = 4990; // 49,90 EUR in centesimi

    // Idempotency: evita duplicati se la richiesta viene ripetuta
    const idempotencyKey = `pi_${orderId}`;

    const paymentIntent = await stripe.paymentIntents.create(
      {
        amount,
        currency: "eur",
        automatic_payment_methods: { enabled: true },
        metadata: { orderId },
      },
      { idempotencyKey }
    );

    res.json({ clientSecret: paymentIntent.client_secret });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: "Impossibile creare PaymentIntent" });
  }
});

Il frontend (non incluso qui) userà clientSecret con Stripe.js per completare il pagamento. Anche in questo flusso, la conferma definitiva dell'ordine deve avvenire con webhook.

Webhook: il cuore dell'integrazione affidabile

I webhook sono fondamentali perché:

  • gestiscono casi in cui l'utente chiude il browser;
  • sono server-to-server, quindi più affidabili del redirect;
  • coprono eventi successivi (refund, contestazioni, aggiornamenti di stato).

Endpoint webhook con verifica firma

Per verificare la firma hai bisogno di STRIPE_WEBHOOK_SECRET. In locale puoi ottenerla con Stripe CLI. L'endpoint deve ricevere il corpo grezzo (raw).

// Webhook: serve raw body, quindi NON usare express.json() su questa route.
app.post(
  "/webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["stripe-signature"];
    let event;

    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      console.error("Firma webhook non valida:", err.message);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    // Gestione eventi principali
    switch (event.type) {
      case "checkout.session.completed": {
        const session = event.data.object;
        // Qui: recupera session.id, session.payment_intent o session.metadata
        // Aggiorna ordine come pagato nel tuo DB
        console.log("Checkout completato:", session.id);
        break;
      }
      case "payment_intent.succeeded": {
        const paymentIntent = event.data.object;
        console.log("PaymentIntent riuscito:", paymentIntent.id);
        // Aggiorna ordine come pagato usando paymentIntent.metadata.orderId
        break;
      }
      case "payment_intent.payment_failed": {
        const paymentIntent = event.data.object;
        console.log("PaymentIntent fallito:", paymentIntent.id);
        // Logga motivo e notifica eventuali sistemi interni
        break;
      }
      default:
        console.log(`Evento non gestito: ${event.type}`);
    }

    // Rispondi 200 per confermare ricezione
    res.json({ received: true });
  }
);

Buone pratiche nella gestione webhook:

  • Processa gli eventi in modo idempotente (stesso evento può arrivare più volte).
  • Registra l'ID evento (event.id) nel database per evitare doppie elaborazioni.
  • Non fare operazioni lente direttamente nel handler: se necessario, invia un job in coda (ad esempio BullMQ, SQS, RabbitMQ).
  • Gestisci errori temporanei e retry.

Stripe CLI e test end-to-end

Stripe mette a disposizione una CLI utile per:

  • inoltrare webhook in locale (forwarding);
  • triggerare eventi di test;
  • verificare rapidamente flussi di pagamento.
# Login (apre browser)
stripe login

# Ascolta eventi e inoltra al tuo server locale
stripe listen --forward-to localhost:4242/webhook

Il comando stripe listen mostrerà un valore whsec_... da mettere in STRIPE_WEBHOOK_SECRET. Puoi poi triggerare eventi:

stripe trigger payment_intent.succeeded
stripe trigger checkout.session.completed

Sicurezza e robustezza: checklist essenziale

  • Calcola importi lato server: non fidarti di prezzi inviati dal client.
  • Verifica firma webhook: senza, chiunque potrebbe chiamare il tuo endpoint e simulare pagamenti.
  • Usa HTTPS in produzione.
  • Idempotency su creazione PaymentIntent e su aggiornamento ordine.
  • Gestisci stati intermedi: un pagamento può richiedere azione (SCA), fallire o rimanere in sospeso.
  • Log e monitoraggio: registra errori e latenza; imposta alert (ad esempio su webhook falliti).

Rimborsi, annulli e riconciliazione

In molti casi dovrai gestire rimborsi (totali o parziali). Un rimborso può essere avviato lato dashboard Stripe oppure via API.

app.post("/refund", async (req, res) => {
  try {
    const { paymentIntentId, amount } = req.body ?? {};
    if (!paymentIntentId) {
      return res.status(400).json({ error: "paymentIntentId obbligatorio" });
    }

    const refund = await stripe.refunds.create({
      payment_intent: paymentIntentId,
      // amount facoltativo: se omesso rimborsa tutto
      amount: typeof amount === "number" ? amount : undefined,
    });

    res.json({ refundId: refund.id, status: refund.status });
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: "Impossibile creare il rimborso" });
  }
});

Anche per rimborsi e contestazioni, usa i webhook (ad esempio eventi charge.refunded o simili) per mantenere allineato il tuo database.

Struttura consigliata per un progetto reale

  • routes: definizione degli endpoint HTTP (checkout, payment intents, refunds).
  • services: logica Stripe (creazioni, recuperi, validazioni, idempotency keys).
  • webhooks: handler separati e testabili.
  • db: ordini, utenti, stato pagamento, eventi webhook processati.
  • config: lettura e validazione delle variabili d'ambiente.

Errori comuni e come evitarli

  • Uso errato del middleware JSON sul webhook: se il body viene parsato in JSON, la verifica firma fallisce. Usa express.raw solo sulla route webhook.
  • Confermare l'ordine sul redirect: la pagina di successo non è una prova definitiva. Usa sempre i webhook.
  • Prezzi dal client: un utente può manomettere la richiesta. Calcola importo/line items sul server.
  • Doppie richieste: implementa idempotency e deduplica eventi webhook.

Conclusione

Per iniziare rapidamente e con un buon livello di sicurezza, Stripe Checkout è spesso la scelta migliore. Se invece hai bisogno di un'esperienza utente completamente personalizzata, Payment Intents ti offre il controllo necessario, a patto di gestire correttamente stati, conferme e webhook. In entrambi i casi, la regola d'oro è: lato server si calcolano importi e si confermano pagamenti tramite webhook.

Torna su