Pagamenti con Stripe in Java Spring Boot

Questo articolo mostra un’integrazione completa di pagamenti con Stripe in un’applicazione Java basata su Spring Boot: dalla configurazione delle chiavi, alla creazione di una sessione di Checkout, fino alla gestione dei webhook per rendere l’elaborazione affidabile e idempotente. Gli esempi sono pensati per un backend che espone API REST e un frontend qualsiasi (web, mobile o SPA) che reindirizza l’utente a Stripe Checkout.

Prerequisiti e obiettivi

  • Java 17+ (consigliato) e Spring Boot 3.x
  • Un account Stripe e una coppia di chiavi API (test e live)

Obiettivi principali:

  1. Creare un endpoint backend che genera una Checkout Session Stripe.
  2. Ricevere gli eventi via webhook (pagamento riuscito, annullato, rimborsato, ecc.).
  3. Persistenza dello stato ordine e idempotenza per evitare doppi aggiornamenti.
  4. Separare test e produzione in modo sicuro (chiavi, endpoint, redirect URL).

Modello concettuale: PaymentIntent, Checkout e webhooks

Stripe fornisce più modalità di incasso. In un progetto Spring Boot tipico:

  • Stripe Checkout: pagina di pagamento ospitata da Stripe. È la strada più veloce e robusta per partire, riduce la superficie PCI e gestisce UX e metodi di pagamento in modo integrato.
  • PaymentIntent: oggetto che rappresenta l’intento di pagamento. Checkout in genere crea e gestisce internamente un PaymentIntent, ma è possibile lavorare anche in modalità “custom payment flow” con Stripe Elements.
  • Webhooks: chiamate server-to-server che notificano eventi affidabili. Non bisogna mai considerare “definitivo” un pagamento soltanto perché l’utente è tornato sulla pagina di success.

La regola d’oro è: lo stato finale dell’ordine va deciso dal backend tramite webhook, non da parametri di ritorno del browser.

Dipendenze Maven

Aggiungi la libreria ufficiale Stripe Java e le dipendenze Spring necessarie. Un esempio di pom.xml minimale:

<dependencies>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
  </dependency>

  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>

  <dependency>
    <groupId>com.stripe</groupId>
    <artifactId>stripe-java</artifactId>
    <version>25.15.0</version>
  </dependency>

  <!-- Facoltativo: persistenza, esempi JPA -->
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
  </dependency>
</dependencies>

Nota: la versione di stripe-java cambia nel tempo; verifica in base al tuo progetto e al tuo processo di aggiornamento.

Configurazione in Spring Boot

Evita di “hardcodare” chiavi. Usa proprietà e variabili d’ambiente. Esempio application.yml:

stripe:
  api-key: ${STRIPE_API_KEY}
  webhook-secret: ${STRIPE_WEBHOOK_SECRET}
  success-url: ${STRIPE_SUCCESS_URL:http://localhost:8080/pay/success}
  cancel-url: ${STRIPE_CANCEL_URL:http://localhost:8080/pay/cancel}

In test, imposta STRIPE_API_KEY e STRIPE_WEBHOOK_SECRET con i valori della Dashboard Stripe (modalità test). In produzione, usa un secret manager (KMS, Vault, parameter store) e limita l’accesso alle variabili.

Bean di inizializzazione Stripe

La libreria Stripe usa un’API key globale (static) oppure per-request. Un approccio semplice:

package com.example.payments.config;

import com.stripe.Stripe;
import jakarta.annotation.PostConstruct;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConfigurationProperties(prefix = "stripe")
public class StripeConfig {

  private String apiKey;

  public String getApiKey() { return apiKey; }
  public void setApiKey(String apiKey) { this.apiKey = apiKey; }

  @PostConstruct
  public void init() {
    Stripe.apiKey = this.apiKey;
  }
}

Se preferisci evitare la chiave globale, puoi passare una RequestOptions con API key per chiamata. In molti progetti la configurazione globale è sufficiente, purché tu non mescoli chiavi test e live nello stesso runtime.

Creazione di una Checkout Session

L’idea è: il backend crea una sessione con importo, valuta e riferimenti al tuo ordine; il frontend riceve l’URL e reindirizza l’utente.

DTO di richiesta

package com.example.payments.api;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;

public class CreateCheckoutSessionRequest {

  @NotBlank
  private String orderId;

  @NotNull
  @Min(1)
  private Long amountCents;

  @NotBlank
  private String currency; // es. "eur"

  private String customerEmail; // facoltativo

  public String getOrderId() { return orderId; }
  public void setOrderId(String orderId) { this.orderId = orderId; }

  public Long getAmountCents() { return amountCents; }
  public void setAmountCents(Long amountCents) { this.amountCents = amountCents; }

  public String getCurrency() { return currency; }
  public void setCurrency(String currency) { this.currency = currency; }

  public String getCustomerEmail() { return customerEmail; }
  public void setCustomerEmail(String customerEmail) { this.customerEmail = customerEmail; }
}

Service Stripe

Inseriamo nel metadata l’identificativo ordine. Questo sarà molto utile quando riceveremo i webhook.

package com.example.payments.service;

import com.stripe.exception.StripeException;
import com.stripe.model.checkout.Session;
import com.stripe.param.checkout.SessionCreateParams;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;

@Service
public class StripeCheckoutService {

  @Value("${stripe.success-url}")
  private String successUrl;

  @Value("${stripe.cancel-url}")
  private String cancelUrl;

  public Session createCheckoutSession(
      String orderId,
      long amountCents,
      String currency,
      String customerEmail
  ) throws StripeException {

    Map<String, String> metadata = new HashMap<>();
    metadata.put("orderId", orderId);

    SessionCreateParams.LineItem.PriceData.ProductData productData =
        SessionCreateParams.LineItem.PriceData.ProductData.builder()
            .setName("Ordine " + orderId)
            .build();

    SessionCreateParams.LineItem.PriceData priceData =
        SessionCreateParams.LineItem.PriceData.builder()
            .setCurrency(currency)
            .setUnitAmount(amountCents)
            .setProductData(productData)
            .build();

    SessionCreateParams.LineItem lineItem =
        SessionCreateParams.LineItem.builder()
            .setQuantity(1L)
            .setPriceData(priceData)
            .build();

    SessionCreateParams.Builder builder =
        SessionCreateParams.builder()
            .setMode(SessionCreateParams.Mode.PAYMENT)
            .setSuccessUrl(successUrl + "?session_id={CHECKOUT_SESSION_ID}")
            .setCancelUrl(cancelUrl)
            .addLineItem(lineItem)
            .putAllMetadata(metadata);

    if (customerEmail != null && !customerEmail.isBlank()) {
      builder.setCustomerEmail(customerEmail);
    }

    return Session.create(builder.build());
  }
}

Controller REST

L’endpoint restituisce l’URL della sessione. Il frontend fa redirect verso quell’URL.

package com.example.payments.api;

import com.example.payments.service.StripeCheckoutService;
import com.stripe.exception.StripeException;
import com.stripe.model.checkout.Session;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api/payments")
public class PaymentsController {

  private final StripeCheckoutService stripeCheckoutService;

  public PaymentsController(StripeCheckoutService stripeCheckoutService) {
    this.stripeCheckoutService = stripeCheckoutService;
  }

  @PostMapping("/checkout-session")
  public ResponseEntity<Map<String, Object>> createCheckoutSession(
      @Valid @RequestBody CreateCheckoutSessionRequest request
  ) throws StripeException {

    Session session = stripeCheckoutService.createCheckoutSession(
        request.getOrderId(),
        request.getAmountCents(),
        request.getCurrency(),
        request.getCustomerEmail()
    );

    return ResponseEntity.ok(Map.of(
        "id", session.getId(),
        "url", session.getUrl()
    ));
  }
}

Consiglio pratico: prima di chiamare Stripe, valida che orderId esista nel tuo database e che l’importo sia quello calcolato dal backend, non quello inviato dal client. Il client può essere manipolato.

Pagina di ritorno: perché non basta

Stripe reindirizza l’utente al tuo success-url o cancel-url. Queste pagine sono utili per UX, ma non sono una conferma definitiva del pagamento: l’utente può chiudere il browser, perdere connessione o ricaricare la pagina. Inoltre, un attaccante potrebbe chiamare direttamente la tua pagina di success senza pagare.

Il modo corretto è aggiornare lo stato ordine quando ricevi un webhook “pagamento completato” e, opzionalmente, mostrare nell’interfaccia uno stato “in verifica” finché il backend non conferma.

Webhook: verifica firma e idempotenza

I webhook arrivano come richieste HTTP POST con un header di firma (tipicamente Stripe-Signature). Devi verificare la firma usando il webhook secret fornito da Stripe; altrimenti chiunque potrebbe inviare eventi falsi.

Endpoint webhook

Importante: l’endpoint deve leggere il corpo raw (stringa) perché la verifica firma dipende dal payload esatto. Evita di farlo deserializzare automaticamente prima della verifica.

package com.example.payments.webhook;

import com.stripe.exception.SignatureVerificationException;
import com.stripe.model.Event;
import com.stripe.net.Webhook;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/stripe")
public class StripeWebhookController {

  @Value("${stripe.webhook-secret}")
  private String webhookSecret;

  private final StripeWebhookHandler handler;

  public StripeWebhookController(StripeWebhookHandler handler) {
    this.handler = handler;
  }

  @PostMapping("/webhook")
  public ResponseEntity<String> handle(
      @RequestBody String payload,
      @RequestHeader("Stripe-Signature") String sigHeader
  ) {
    Event event;
    try {
      event = Webhook.constructEvent(payload, sigHeader, webhookSecret);
    } catch (SignatureVerificationException e) {
      return ResponseEntity.status(400).body("Invalid signature");
    } catch (Exception e) {
      return ResponseEntity.status(400).body("Bad request");
    }

    handler.process(event);
    return ResponseEntity.ok("ok");
  }
}

Gestore eventi con idempotenza

Stripe può ritentare i webhook. Inoltre, la tua app può ricevere lo stesso evento più volte in caso di timeout. Devi rendere il processing idempotente: se l’evento è già stato gestito, non devi applicare di nuovo la stessa transazione (ad esempio non devi “marcare pagato” due volte o scalare stock due volte).

Una strategia comune:

  • Salvare gli event.id già processati in una tabella (processed_stripe_events).
  • Gestire gli aggiornamenti ordine in una transazione DB.
  • Applicare un vincolo univoco su eventId per bloccare i duplicati.
package com.example.payments.webhook;

import com.stripe.model.Event;
import com.stripe.model.checkout.Session;
import com.stripe.net.ApiResource;
import org.springframework.stereotype.Service;

@Service
public class StripeWebhookHandler {

  private final StripeWebhookProcessingService processingService;

  public StripeWebhookHandler(StripeWebhookProcessingService processingService) {
    this.processingService = processingService;
  }

  public void process(Event event) {
    switch (event.getType()) {
      case "checkout.session.completed" -> {
        Session session = (Session) ApiResource.GSON.fromJson(
            event.getDataObjectDeserializer().getRawJson(),
            Session.class
        );
        processingService.onCheckoutSessionCompleted(event.getId(), session);
      }
      case "charge.refunded" -> processingService.onChargeRefunded(event.getId(), event);
      default -> processingService.onUnhandled(event.getId(), event.getType());
    }
  }
}

Il deserializzatore in Stripe Java può essere gestito in modi diversi a seconda della versione; l’obiettivo rimane lo stesso: estrarre dal payload i campi che ti servono (tipicamente session.id, payment_intent, metadata).

Esempio di servizio transazionale (con JPA)

package com.example.payments.webhook;

import jakarta.persistence.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.stripe.model.checkout.Session;

import java.time.Instant;

@Entity
@Table(name = "processed_stripe_events", uniqueConstraints = {
    @UniqueConstraint(name = "uk_processed_event", columnNames = "event_id")
})
class ProcessedStripeEvent {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(name = "event_id", nullable = false)
  private String eventId;

  @Column(name = "processed_at", nullable = false)
  private Instant processedAt = Instant.now();

  protected ProcessedStripeEvent() {}

  public ProcessedStripeEvent(String eventId) {
    this.eventId = eventId;
  }
}

interface ProcessedStripeEventRepository extends org.springframework.data.repository.CrudRepository<ProcessedStripeEvent, Long> {
  boolean existsByEventId(String eventId);
}

@Service
public class StripeWebhookProcessingService {

  private final ProcessedStripeEventRepository processedRepo;
  private final OrderService orderService;

  public StripeWebhookProcessingService(
      ProcessedStripeEventRepository processedRepo,
      OrderService orderService
  ) {
    this.processedRepo = processedRepo;
    this.orderService = orderService;
  }

  @Transactional
  public void onCheckoutSessionCompleted(String eventId, Session session) {
    if (processedRepo.existsByEventId(eventId)) {
      return; // idempotenza
    }

    String orderId = session.getMetadata() != null ? session.getMetadata().get("orderId") : null;
    if (orderId == null) {
      processedRepo.save(new ProcessedStripeEvent(eventId));
      return;
    }

    // Aggiorna l’ordine solo se lo stato è coerente (es. da PENDING a PAID)
    orderService.markPaid(orderId, session.getId(), session.getPaymentIntent());

    processedRepo.save(new ProcessedStripeEvent(eventId));
  }

  @Transactional
  public void onChargeRefunded(String eventId, com.stripe.model.Event rawEvent) {
    if (processedRepo.existsByEventId(eventId)) {
      return;
    }
    // Qui potresti estrarre charge / payment_intent e marcare l'ordine come REFUNDED
    processedRepo.save(new ProcessedStripeEvent(eventId));
  }

  @Transactional
  public void onUnhandled(String eventId, String type) {
    if (!processedRepo.existsByEventId(eventId)) {
      processedRepo.save(new ProcessedStripeEvent(eventId));
    }
  }
}

La tabella per gli eventi processati è minimale; in ambienti ad alto volume potresti aggiungere: tipo evento, payload hash, chiave ordine e politiche di retention.

Validazione dello stato: cosa controllare in checkout.session.completed

Prima di marcare un ordine come pagato, verifica almeno:

  • Importo e valuta: confronta con i dati dell’ordine lato server.
  • Modalità e stato: per Checkout in modalità PAYMENT, valuta anche payment_status e il PaymentIntent.
  • Metadata: assicurati che orderId sia presente e valido.

Una variante ancora più robusta è recuperare da Stripe (server-to-server) l’oggetto session o payment intent usando l’ID ricevuto, così da non dipendere esclusivamente dal payload del webhook. Questo aggiunge una chiamata, ma rende il controllo più affidabile.

Gestione degli importi: unità minime e arrotondamenti

Stripe gestisce gli importi in unità minime (es. centesimi per EUR). Evita i float:

  • Salva importi come long in centesimi nel backend.
  • Calcola tasse e sconti lato server.
  • Mostra al client importi già calcolati, ma non fidarti dei valori che ti invia.

Sicurezza: chiavi, CORS e reti

  • Secret key: resta solo sul backend. Nel frontend usa (se necessario) la publishable key, mai la secret.
  • Webhook secret: tienilo separato per ogni endpoint (test vs live). Se lo ruoti, aggiorna subito la configurazione.
  • Endpoint webhook pubblico: non richiede autenticazione utente, ma richiede verifica firma e rate limiting.
  • CSRF: per webhook (API machine-to-machine) spesso conviene escludere l’endpoint dal CSRF se stai usando Spring Security, mantenendo però la verifica firma Stripe.

Test in locale

In sviluppo puoi usare Stripe CLI per inoltrare i webhook verso la tua macchina locale e ottenere un webhook secret dedicato. In alternativa, esponi temporaneamente l’endpoint con un tunnel (es. ngrok) e configura l’URL nella Dashboard.

Un flusso di test tipico:

  1. Avvia l’app Spring Boot.
  2. Chiama /api/payments/checkout-session e reindirizza a url.
  3. Completa il pagamento con una carta di test.
  4. Verifica che il webhook aggiorni correttamente lo stato ordine nel database.

Strategia di persistenza degli ordini

Un ordine dovrebbe avere almeno:

  • orderId interno
  • amountCents, currency
  • status: PENDING, PAID, CANCELED, REFUNDED, FAILED
  • stripeCheckoutSessionId, stripePaymentIntentId (quando disponibili)

Mantieni una macchina a stati semplice e valida le transizioni (es. non passare da REFUNDED a PAID). In presenza di concorrenza, usa lock ottimistico (version column) o transazioni con isolamento adeguato.

Handling errori e resilienza

  • Timeout Stripe: gestisci eccezioni e ritenta con prudenza. Evita retry “ciechi” sul client.
  • Webhook failures: se il tuo endpoint risponde 500, Stripe ritenterà. Registra log strutturati e allarmi.
  • Osservabilità: logga event.id, orderId, session.id e stati prima/dopo.

Estensioni comuni

  • Abbonamenti: usa Checkout in modalità SUBSCRIPTION e gestisci eventi come invoice.paid, customer.subscription.updated.
  • Rimborsi: crea refund lato server e gestisci il webhook charge.refunded o eventi correlati.
  • Pagamenti salvati: usa Customer e SetupIntent per salvare metodi di pagamento in modo conforme.
  • Multivaluta e imposte: valuta Stripe Tax o calcoli server-side con regole fiscali, in base al caso d’uso.

Checklist finale

  1. Chiavi Stripe in variabili d’ambiente e separate per test/live.
  2. Creazione sessione Checkout con importo calcolato dal backend.
  3. Metadata con orderId e riferimenti persistiti nel DB.
  4. Webhook con verifica firma e processing idempotente.
  5. Stato ordine aggiornato solo tramite webhook, non tramite redirect.
  6. Log e metriche per eventi, errori e retry.

Con questa struttura hai un’integrazione solida, scalabile e sicura: Checkout ti permette di partire rapidamente, mentre i webhook rendono il processo affidabile anche in presenza di rete instabile, retry e scenari reali di produzione.

Torna su