Salta al contenuto principale

07 - Integrazione Pagamenti Headless: Stripe Elements, Payment Intents e SCA Europeo

Collega Stripe a uno storefront headless senza fidarti del browser: importo deciso dal server, conferma da webhook firmato e protezione da eventi duplicati.

Cosa ne pensi di questo articolo?

07 - Integrazione Pagamenti Headless: Stripe Elements, Payment Intents e SCA Europeo
HeadlessCommerceFoto:Christina MorilloPexels

In Breve

Collega Stripe a uno storefront headless senza fidarti del browser: importo deciso dal server, conferma da webhook firmato e protezione da eventi duplicati.

Headless Commerce 12 min

Come collegare Stripe a uno storefront headless senza fidarti del browser: importo deciso dal server, autenticazione 3D Secure gestita da Stripe, conferma dell'ordine affidata ai webhook verificati e consegne duplicate o fuori ordine sotto controllo.

stripe payment intents SCA PSD2 stripe webhook idempotenza pagamenti headless

Il problema: il browser mente, la rete cade, gli eventi arrivano due volte

Un pagamento in uno storefront headless attraversa quattro luoghi: il browser del cliente, il tuo server, Stripe e la banca. Ognuno può sbagliare o essere ostile. Il browser può essere manomesso e dichiarare un importo di un centesimo; la rete può cadere dopo che la banca ha addebitato; Stripe può recapitare lo stesso avviso più volte. L’integrazione che funziona in demo e perde soldi in produzione si riconosce da quattro assunzioni: «il browser dice la verità sul totale», «se il client dice successo, l’ordine è pagato», «ogni messaggio sul webhook viene da Stripe», «ogni evento arriva una volta sola, in ordine». Questo articolo smonta le quattro, nell’ordine, con codice che puoi rieseguire.

Quale API usare: Payment Intents oggi non è sempre la risposta

Prima della tecnica, un aggiornamento che molti tutorial non riportano. Nella pagina Accept a payment (consultata il 2026-11-12) Stripe raccomanda l’API Checkout Sessions con il Payment Element per la maggior parte delle integrazioni, e descrive i Payment Intents come la scelta che richiede più codice: serve quando hai un flusso di pagamento complesso che Checkout Sessions non copre. Checkout Sessions gestisce da sé calcolo delle tasse, sconti e spedizione e alcune funzioni sono disponibili solo lì. La pagina dei Payment Intents li presenta come l’API per flussi complessi con uno stato che cambia durante il ciclo di vita del pagamento.

In uno storefront headless la scelta dipende da dove vive la verità sull’ordine. Se il backend commerce (Medusa, Saleor, Shopify) crea già l’ordine e calcola tasse e spedizione, ti basta un pagamento per un importo noto e i Payment Intents sono lineari. Se vuoi che sia Stripe a calcolare righe, tasse e sconti, valuta Checkout Sessions. Il resto dell’articolo vale per Payment Intents, ma le due regole che contano (importo dal server, conferma dal webhook) valgono per entrambe. Quanto alla normativa: Stripe riporta che i Payment Intents sono pronti per la Strong Customer Authentication in vigore dal 14 settembre 2019, mentre la vecchia Charges API non lo è; le esenzioni le applicano Stripe e la banca emittente, non il tuo codice, e quali valgano per te va chiesto a un consulente.

Primo errore da evitare: l’importo viene dal server

Il flusso si apre con una chiamata dal browser al tuo server, che crea il Payment Intent e restituisce il suo client secret: una chiave che permette al browser di leggere e confermare quel pagamento, ma non di cambiarne l’importo. Nel listato, il browser invia solo ordineId; l’importo amount si legge dall’ordine salvato dal tuo backend, in centesimi interi, e la richiesta porta una idempotencyKey costruita sull’ordine.

Sull'importo c'è una trappola: «in centesimi» vale per l'euro, non per ogni valuta (lo yen non ha decimali). La regola e un listato eseguito sono in Adapter, Decorator e Facade.

// app/api/checkout/payment-intent/route.ts - Next.js App Router, server
import Stripe from 'stripe';
import { NextResponse } from 'next/server';
import { caricaOrdine } from '@/lib/ordini'; // il TUO accesso al database degli ordini

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); // chiave segreta: solo sul server

export async function POST(req: Request) {
  const { ordineId } = await req.json();

  // L'importo NON arriva dal browser: si legge dall'ordine salvato sul server.
  const ordine = await caricaOrdine(ordineId);
  if (!ordine || ordine.stato !== 'in_attesa') {
    return NextResponse.json({ errore: 'ordine non valido' }, { status: 400 });
  }

  const intent = await stripe.paymentIntents.create(
    {
      amount: ordine.totaleCents, // intero, nella valuta piu' piccola (centesimi per l'euro)
      currency: 'eur',
      automatic_payment_methods: { enabled: true },
      metadata: { ordineId: ordine.id },
    },
    // Una ripetizione della richiesta (rete, doppio clic) restituisce lo stesso intent
    { idempotencyKey: `intent-${ordine.id}` },
  );

  return NextResponse.json({ clientSecret: intent.client_secret });
}

Questo blocco non è stato eseguito: richiede la libreria stripe, una chiave segreta e un database; è uno schema da adattare, e @/lib/ordini è il tuo accesso agli ordini. Le due scelte che contano sono nel codice. Primo, amount viene dall’ordine, non dalla richiesta: la versione ingenua accetta un importo scelto dal client. Secondo, la chiave di idempotenza: la documentazione sulle richieste idempotenti spiega che permette di ripetere una richiesta dopo un errore di connessione senza creare un secondo oggetto, e la pagina dei Payment Intents consiglia di costruirla sull’identificativo di carrello o ordine. Senza, un doppio clic o un tentativo dopo un timeout creano due pagamenti per lo stesso ordine.

Secondo errore: dichiarare pagato un ordine dal client

Nel browser, il Payment Element raccoglie i dati e stripe.confirmPayment li conferma. Se la banca richiede l’autenticazione (3D Secure), la libreria apre la finestra o reindirizza alla pagina della banca e poi torna al return_url. Con l’opzione redirect: 'if_required', descritta nel riferimento di Stripe.js, il reindirizzamento avviene solo per i metodi di pagamento che lo richiedono; la stessa pagina avverte che in quel caso devi gestire separatamente le conferme con e senza reindirizzamento.

// components/PaymentForm.tsx - Client Component
'use client';
import { useState } from 'react';
import { PaymentElement, useElements, useStripe } from '@stripe/react-stripe-js';

export function PaymentForm({ ordineId }: { ordineId: string }) {
  const stripe = useStripe();
  const elements = useElements();
  const [errore, setErrore] = useState<string | null>(null);
  const [inCorso, setInCorso] = useState(false);

  async function onSubmit(e: React.FormEvent) {
    e.preventDefault();
    if (!stripe || !elements) return; // Stripe.js non ancora caricato
    setInCorso(true);

    const { error, paymentIntent } = await stripe.confirmPayment({
      elements,
      confirmParams: { return_url: `${location.origin}/checkout/esito?ordine=${ordineId}` },
      redirect: 'if_required', // redirect solo per i metodi che lo richiedono
    });

    if (error) setErrore(error.message ?? 'Pagamento non riuscito');
    // Qui NON si marca l'ordine come pagato: lo decide il webhook sul server.
    else if (paymentIntent?.status === 'succeeded') location.assign(`/checkout/esito?ordine=${ordineId}`);
    setInCorso(false);
  }

  return (
    <form onSubmit={onSubmit}>
      <PaymentElement />
      {errore && <p role="alert">{errore}</p>}
      <button disabled={!stripe || inCorso}>Paga</button>
    </form>
  );
}

Anche questo blocco non è stato eseguito (serve il progetto React con le librerie di Stripe). Guarda il commento centrale: il client non marca l’ordine come pagato. Se lo facesse, un cliente che chiude la scheda un istante prima del ritorno, o un utente che chiama l’endpoint a mano, lascerebbe lo stato incoerente con la banca. Il browser mostra l’esito all’utente; chi cambia lo stato dell’ordine è il server, quando riceve da Stripe l’evento che dice cosa è successo davvero.

Terzo errore: fidarsi del webhook senza verificarlo

L’endpoint dei webhook è un indirizzo pubblico: chiunque può inviargli un messaggio che finge di essere Stripe. Per questo ogni consegna porta l’intestazione Stripe-Signature, con un timestamp e una o più firme v1. La documentazione dei webhook raccomanda le librerie ufficiali, ma descrive anche la verifica manuale in quattro passi: separare il timestamp e le firme; costruire la stringa timestamp + "." + corpo non modificato; calcolare l’HMAC-SHA256 con il segreto dell’endpoint; confrontare a tempo costante con ogni v1 e poi controllare che il timestamp rientri nella tolleranza. Il listato li implementa uno per uno ed è stato eseguito con Node 24.

import { createHmac, timingSafeEqual } from 'node:crypto';

// Verifica manuale di Stripe-Signature, secondo i quattro passi della documentazione.
export function verificaStripe(corpoGrezzo: string, intestazione: string, segreto: string, adesso = Date.now() / 1000, tolleranzaSec = 300) {
  // Passo 1: dividere per "," e poi per "="; tenere solo t e v1 (ignorare v0, secret, destination, payload...)
  const voci = intestazione.split(',').map((v) => v.split('=') as [string, string]);
  const t = voci.find(([k]) => k === 't')?.[1];
  const v1 = voci.filter(([k]) => k === 'v1').map(([, v]) => v);
  if (!t || v1.length === 0) return 'intestazione incompleta';

  // Passo 2 e 3: HMAC-SHA256 di "<t>.<corpo non modificato>" con il segreto dell'endpoint
  const atteso = createHmac('sha256', segreto).update(`${t}.${corpoGrezzo}`).digest();

  // Passo 4: confronto a tempo costante con OGNI v1; timingSafeEqual lancia se le lunghezze differiscono
  const valida = v1.some((firma) => {
    const bytes = Buffer.from(firma, 'hex');
    return bytes.length === atteso.length && timingSafeEqual(bytes, atteso);
  });
  if (!valida) return 'firma non valida';
  if (Math.abs(adesso - Number(t)) > tolleranzaSec) return 'timestamp fuori tolleranza (possibile replay)';
  return 'ok';
}

if (import.meta.main) {
  const segreto = 'whsec_di_prova';
  const corpo = '{"id":"evt_1","type":"payment_intent.succeeded"}';
  const t = Math.floor(Date.now() / 1000);
  const firmaDi = (ts: number, c: string) => createHmac('sha256', segreto).update(`${ts}.${c}`).digest('hex');

  console.log('firma corretta        :', verificaStripe(corpo, `t=${t},v1=${firmaDi(t, corpo)},v0=ignorata`, segreto));
  console.log('due v1 (rotazione)    :', verificaStripe(corpo, `t=${t},v1=${'0'.repeat(64)},v1=${firmaDi(t, corpo)}`, segreto));
  console.log('corpo modificato      :', verificaStripe(corpo.replace('evt_1', 'evt_2'), `t=${t},v1=${firmaDi(t, corpo)}`, segreto));
  console.log('corpo riformattato    :', verificaStripe(JSON.stringify(JSON.parse(corpo), null, 2), `t=${t},v1=${firmaDi(t, corpo)}`, segreto));
  const vecchio = t - 3600;
  console.log('firma valida, 1h fa   :', verificaStripe(corpo, `t=${vecchio},v1=${firmaDi(vecchio, corpo)}`, segreto));
  console.log('firma troncata        :', verificaStripe(corpo, `t=${t},v1=abcd`, segreto));
}

// Output:
// firma corretta        : ok
// due v1 (rotazione)    : ok
// corpo modificato      : firma non valida
// corpo riformattato    : firma non valida
// firma valida, 1h fa   : timestamp fuori tolleranza (possibile replay)
// firma troncata        : firma non valida

Ognuno dei sei casi dell’uscita corrisponde a una regola della documentazione. Il caso con due v1 esiste perché, quando ruoti il segreto di un endpoint, Stripe firma con entrambi per un periodo e basta che una sia valida. Il corpo «riformattato» ha lo stesso contenuto JSON con spazi diversi e fallisce: per questo il codice deve leggere il corpo grezzo (in Next.js con req.text(), non req.json()). La firma valida ma vecchia di un'ora viene rifiutata dal controllo del timestamp: la tolleranza predefinita delle librerie ufficiali è di 5 minuti, e impostarla a zero disabilita il controllo. La riga con la lunghezza controllata prima di timingSafeEqual serve perché quella funzione lancia un errore se i due buffer hanno lunghezze diverse: la firma troncata dell’ultimo caso mostra un rifiuto pulito invece di un 500. In produzione usa la libreria ufficiale; questo codice serve a capire che cosa fa.

Quarto errore: credere che gli eventi arrivino una volta, in ordine

Sempre secondo la pagina dei webhook, Stripe ritenta la consegna fino a tre giorni, con attese crescenti, in modalità live: se il tuo endpoint risponde con un errore o va in timeout, lo stesso evento torna. Inoltre non garantisce che gli eventi arrivino nell’ordine in cui sono stati generati, e consiglia di tracciare gli identificativi degli eventi per riconoscere i duplicati invece di usare il campo created. Altro obbligo: restituire un 2xx rapidamente, prima di svolgere logica lenta.

// Stripe puo' consegnare lo stesso evento piu' volte e non garantisce l'ordine di consegna.
type Evento = { id: string; tipo: 'payment_intent.payment_failed' | 'payment_intent.succeeded'; ordineId: string };
type Stato = 'in_attesa' | 'pagato' | 'fallito';

const giaVisti = new Set<string>(); // in produzione: tabella con vincolo UNIQUE su event_id
const ordini = new Map<string, Stato>([['ord_1', 'in_attesa']]);

export function applica(e: Evento): string {
  if (giaVisti.has(e.id)) return `${e.id}: duplicato, ignorato`;
  giaVisti.add(e.id);

  const stato = ordini.get(e.ordineId);
  if (stato === undefined) return `${e.id}: ordine sconosciuto`;
  if (stato === 'pagato') return `${e.id}: ordine gia' pagato, nessuna regressione`; // un "fallito" tardivo non annulla un pagato

  ordini.set(e.ordineId, e.tipo === 'payment_intent.succeeded' ? 'pagato' : 'fallito');
  return `${e.id}: ordine -> ${ordini.get(e.ordineId)}`;
}

if (import.meta.main) {
  const successo: Evento = { id: 'evt_B', tipo: 'payment_intent.succeeded', ordineId: 'ord_1' };
  const fallito: Evento = { id: 'evt_A', tipo: 'payment_intent.payment_failed', ordineId: 'ord_1' };
  // Il primo tentativo con la carta e' fallito, il secondo e' riuscito, ma gli eventi arrivano al contrario e uno due volte
  for (const e of [successo, successo, fallito]) console.log(applica(e));
  console.log('stato finale:', ordini.get('ord_1'));
}

// Output:
// evt_B: ordine -> pagato
// evt_B: duplicato, ignorato
// evt_A: ordine gia' pagato, nessuna regressione
// stato finale: pagato

Lo scenario del listato è plausibile: il primo tentativo con la carta fallisce, il secondo riesce, e gli eventi si presentano al contrario con un duplicato. L’uscita mostra tre difese. L’insieme giaVisti scarta il duplicato; in produzione è una tabella con vincolo di unicità sull’ID evento, perché un insieme in memoria non sopravvive a un riavvio. Il controllo dello stato impedisce che un evento di fallimento tardivo riporti a «fallito» un ordine già pagato. Lo stato finale è pagato. Il limite: questo modello ha tre stati; un ordine reale ha rimborsi, contestazioni e pagamenti parziali, e ciascuno aggiunge regole di transizione da definire a tavolino.

Dove la tua piattaforma commerce entra in gioco

Se hai scelto una delle piattaforme della serie, controlla prima se ha già un’integrazione di pagamento: Saleor usa app di pagamento con le sue transazioni (vedi l’articolo su Saleor), Medusa ha un modulo dedicato con provider (articolo su Medusa) e Shopify gestisce il pagamento nel proprio checkout. Costruire il tuo flusso con Stripe, come qui, ha senso quando il backend non ti dà un modulo già pronto o quando hai bisogno di un controllo che non offre. In tutti gli altri casi, scrivere meno codice di pagamento è la scelta più prudente. Per il collegamento con il carrello e il calcolo del totale lato server, riparti dall’articolo sullo storefront Next.js. Quando il flusso funziona, misura anche la velocità della pagina di pagamento con le soglie dell’articolo su Core Web Vitals e SEO.

Che cosa sai fare adesso

Sai scegliere tra Checkout Sessions e Payment Intents con il criterio giusto, creare un pagamento il cui importo e la cui idempotenza dipendono dal server, tenere la conferma dell’ordine fuori dal browser, verificare a mano la firma di un webhook Stripe (e perché serve il corpo grezzo) e proteggere l’ordine da duplicati e da eventi fuori ordine. Nel capitolo sul content commerce la serie passa dal pagamento ai contenuti.

Fonti

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti