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:
- Creare un endpoint backend che genera una Checkout Session Stripe.
- Ricevere gli eventi via webhook (pagamento riuscito, annullato, rimborsato, ecc.).
- Persistenza dello stato ordine e idempotenza per evitare doppi aggiornamenti.
- 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.idgià processati in una tabella (processed_stripe_events). - Gestire gli aggiornamenti ordine in una transazione DB.
- Applicare un vincolo univoco su
eventIdper 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_statuse il PaymentIntent. - Metadata: assicurati che
orderIdsia 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:
- Avvia l’app Spring Boot.
- Chiama
/api/payments/checkout-sessione reindirizza aurl. - Completa il pagamento con una carta di test.
- 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.ide 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.refundedo 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
- Chiavi Stripe in variabili d’ambiente e separate per test/live.
- Creazione sessione Checkout con importo calcolato dal backend.
- Metadata con
orderIde riferimenti persistiti nel DB. - Webhook con verifica firma e processing idempotente.
- Stato ordine aggiornato solo tramite webhook, non tramite redirect.
- 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.