Le contexte : une marketplace de billetterie événementielle
En janvier 2026, j’ai développé standup.myticketing.ch, une plateforme permettant aux organisateurs d’événements de vendre des billets tout en gérant automatiquement la répartition des revenus.
Le défi : chaque organisateur doit recevoir ses paiements directement sur son compte bancaire, tandis que la plateforme prélève une commission.
La solution : Stripe Connect en mode “Separate Charges and Transfers” avec Spring Boot 3.2.
Pourquoi Stripe Connect (et pas Stripe standard)
Stripe classique vs Stripe Connect
Stripe classique
Client → Paie la plateforme → Plateforme reverse à l'organisateur
❌ Complexité comptable
❌ Statut de PSP requis (réglementations lourdes)
❌ Gestion manuelle des transferts
Stripe Connect
Client → Paie directement l'organisateur → Commission automatique vers plateforme
✅ Conformité légale automatique
✅ Chaque organisateur a son compte Stripe
✅ Répartition des revenus native
Architecture complète
Stack technique
<!-- pom.xml -->
<properties>
<java.version>21</java.version>
<stripe.version>25.1.0</stripe.version>
</properties>
<dependencies>
<dependency>
<groupId>com.stripe</groupId>
<artifactId>stripe-java</artifactId>
<version>${stripe.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
</dependencies>
Configuration Stripe
// config/StripeConfig.java
@Configuration
public class StripeConfig {
@Value("${stripe.secret-key}")
private String secretKey;
@PostConstruct
public void init() {
Stripe.apiKey = secretKey;
}
@Bean
public RequestOptions platformRequestOptions() {
return RequestOptions.builder()
.setApiKey(secretKey)
.build();
}
}
# application.yml
stripe:
secret-key: ${STRIPE_SECRET_KEY}
publishable-key: ${STRIPE_PUBLISHABLE_KEY}
webhook-secret: ${STRIPE_WEBHOOK_SECRET}
platform-fee-percent: 8
app:
frontend-url: ${APP_FRONTEND_URL:http://localhost:3000}
currency: chf
Flux complet : onboarding d’un organisateur
Étape 1 : Créer un compte Connect
// service/StripeConnectService.java
@Service
@RequiredArgsConstructor
public class StripeConnectService {
@Value("${app.frontend-url}")
private String frontendUrl;
public OnboardOrganizerResponse createConnectedAccount(
OnboardOrganizerRequest request
) throws StripeException {
// 1. Créer le compte Connect
Map<String, Object> accountParams = Map.of(
"type", "express", // Onboarding simplifié
"country", "CH",
"email", request.getEmail(),
"capabilities", Map.of(
"card_payments", Map.of("requested", true),
"transfers", Map.of("requested", true)
),
"business_type", "individual"
);
Account account = Account.create(accountParams);
// 2. Créer le lien d'onboarding
Map<String, Object> linkParams = Map.of(
"account", account.getId(),
"refresh_url", frontendUrl + "/onboarding/refresh",
"return_url", frontendUrl + "/onboarding/complete",
"type", "account_onboarding"
);
AccountLink accountLink = AccountLink.create(linkParams);
// 3. Sauvegarder en base
Organizer organizer = Organizer.builder()
.email(request.getEmail())
.name(request.getName())
.stripeAccountId(account.getId())
.onboardingComplete(false)
.createdAt(LocalDateTime.now())
.build();
organizerRepository.save(organizer);
return OnboardOrganizerResponse.builder()
.accountId(account.getId())
.onboardingUrl(accountLink.getUrl())
.build();
}
}
Étape 2 : Controller REST
// controller/StripeController.java
@RestController
@RequestMapping("/api/stripe")
@RequiredArgsConstructor
public class StripeController {
private final StripeConnectService stripeService;
@PostMapping("/connect/onboard")
public ResponseEntity<OnboardOrganizerResponse> onboardOrganizer(
@Valid @RequestBody OnboardOrganizerRequest request
) {
try {
OnboardOrganizerResponse response = stripeService
.createConnectedAccount(request);
return ResponseEntity.ok(response);
} catch (StripeException e) {
return ResponseEntity.badRequest().build();
}
}
}
Étape 3 : Le modèle de données
// entity/Organizer.java
@Entity
@Table(name = "organizers")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Organizer {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String name;
@Column(name = "stripe_account_id", unique = true)
private String stripeAccountId;
@Column(name = "onboarding_complete")
private Boolean onboardingComplete;
@Column(name = "charges_enabled")
private Boolean chargesEnabled;
@Column(name = "payouts_enabled")
private Boolean payoutsEnabled;
@OneToMany(mappedBy = "organizer")
private List<Event> events;
@CreationTimestamp
private LocalDateTime createdAt;
}
Créer un paiement avec commission automatique
Le Payment Intent avec application_fee
public PaymentIntentResponse createPaymentIntent(
PaymentIntentRequest request
) throws StripeException {
// 1. Récupérer l'événement et l'organisateur
Event event = eventRepository.findById(request.getEventId())
.orElseThrow(() -> new NotFoundException("Event not found"));
Organizer organizer = event.getOrganizer();
if (!organizer.getChargesEnabled()) {
throw new IllegalStateException("Organizer not ready for payments");
}
// 2. Calculer montants (en centimes)
Long amountInCents = request.getAmount() * 100;
Long platformFee = (long) (amountInCents * platformFeePercent / 100);
// 3. Créer le Payment Intent
Map<String, Object> params = Map.of(
"amount", amountInCents,
"currency", "chf",
"application_fee_amount", platformFee, // Commission automatique
"transfer_data", Map.of(
"destination", organizer.getStripeAccountId()
),
"metadata", Map.of(
"event_id", event.getId().toString(),
"organizer_id", organizer.getId().toString(),
"customer_email", request.getCustomerEmail()
)
);
PaymentIntent intent = PaymentIntent.create(params);
// 4. Créer la commande en base
Order order = Order.builder()
.event(event)
.customerEmail(request.getCustomerEmail())
.amount(request.getAmount())
.platformFee(platformFee / 100.0)
.stripePaymentIntentId(intent.getId())
.status("pending")
.createdAt(LocalDateTime.now())
.build();
orderRepository.save(order);
return PaymentIntentResponse.builder()
.clientSecret(intent.getClientSecret())
.paymentIntentId(intent.getId())
.build();
}
Répartition automatique des fonds
Paiement client : 100 CHF
├─ 92 CHF → Compte de l'organisateur
└─ 8 CHF → Compte de la plateforme (commission)
Stripe s’occupe de tout : virements, comptabilité, conformité légale.
Gestion des webhooks : le vrai défi
Pourquoi les webhooks sont critiques
Les Payment Intents peuvent réussir/échouer après la requête initiale :
- Authentification 3D Secure tardive
- Vérification bancaire asynchrone
- Paiements différés
Sans webhook : commandes dans un état incohérent.
Configuration du endpoint
// webhook/WebhookController.java
@RestController
@RequestMapping("/api/webhooks")
@Slf4j
public class WebhookController {
@Value("${stripe.webhook-secret}")
private String webhookSecret;
private final OrderRepository orderRepository;
@PostMapping("/stripe")
public ResponseEntity<String> handleStripeWebhook(
@RequestBody String payload,
@RequestHeader("Stripe-Signature") String sigHeader
) {
Event event;
try {
// 1. Vérifier la signature (sécurité)
event = Webhook.constructEvent(
payload,
sigHeader,
webhookSecret
);
} catch (SignatureVerificationException e) {
log.error("Invalid webhook signature");
return ResponseEntity.status(400).body("Invalid signature");
}
// 2. Traiter l'événement
switch (event.getType()) {
case "payment_intent.succeeded":
handlePaymentSuccess(event);
break;
case "payment_intent.payment_failed":
handlePaymentFailure(event);
break;
case "account.updated":
handleAccountUpdate(event);
break;
default:
log.info("Unhandled event type: {}", event.getType());
}
return ResponseEntity.ok("OK");
}
private void handlePaymentSuccess(Event event) {
PaymentIntent intent = (PaymentIntent) event
.getDataObjectDeserializer()
.getObject()
.orElseThrow();
Order order = orderRepository
.findByStripePaymentIntentId(intent.getId())
.orElseThrow();
order.setStatus("completed");
order.setPaidAt(LocalDateTime.now());
orderRepository.save(order);
log.info("Payment succeeded for order {}", order.getId());
}
private void handlePaymentFailure(Event event) {
PaymentIntent intent = (PaymentIntent) event
.getDataObjectDeserializer()
.getObject()
.orElseThrow();
Order order = orderRepository
.findByStripePaymentIntentId(intent.getId())
.orElseThrow();
order.setStatus("failed");
order.setFailureReason(intent.getLastPaymentError().getMessage());
orderRepository.save(order);
log.warn("Payment failed for order {}: {}",
order.getId(),
intent.getLastPaymentError().getMessage()
);
}
private void handleAccountUpdate(Event event) {
Account account = (Account) event
.getDataObjectDeserializer()
.getObject()
.orElseThrow();
Organizer organizer = organizerRepository
.findByStripeAccountId(account.getId())
.orElseThrow();
organizer.setChargesEnabled(account.getChargesEnabled());
organizer.setPayoutsEnabled(account.getPayoutsEnabled());
// Vérifier si onboarding est terminé
if (account.getDetailsSubmitted() &&
account.getChargesEnabled()) {
organizer.setOnboardingComplete(true);
}
organizerRepository.save(organizer);
log.info("Account updated for organizer {}", organizer.getId());
}
}
Les pièges que j’ai rencontrés
Piège #1 : Montants en centimes
Erreur : envoyer 100 pour 100 CHF.
// ❌ Mauvais
PaymentIntent.create(Map.of("amount", 100, "currency", "chf"));
// → Crée un paiement de 1 CHF !
// ✅ Bon
Long amountInCents = amountInFrancs * 100;
PaymentIntent.create(Map.of("amount", amountInCents, "currency", "chf"));
Piège #2 : Webhooks non idempotents
Problème : Stripe peut renvoyer le même webhook plusieurs fois.
Solution : idempotence par ID d’événement
@Entity
@Table(name = "processed_webhook_events")
public class ProcessedWebhookEvent {
@Id
private String stripeEventId;
private LocalDateTime processedAt;
}
// Dans le webhook handler
if (processedEventRepository.existsById(event.getId())) {
log.info("Event already processed: {}", event.getId());
return ResponseEntity.ok("Already processed");
}
// Traiter l'événement...
processedEventRepository.save(new ProcessedWebhookEvent(
event.getId(),
LocalDateTime.now()
));
Piège #3 : Tester les webhooks en local
Impossible de recevoir des webhooks sur localhost… ou presque.
Solution : Stripe CLI
# Installation
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks vers localhost
stripe listen --forward-to localhost:8080/api/webhooks/stripe
# Trigger un événement de test
stripe trigger payment_intent.succeeded
Copier le webhook secret affiché et l’ajouter à application.yml :
stripe:
webhook-secret: whsec_xxxxxxxxxxxxx
Piège #4 : Gestion des devises
Stripe Connect supporte 135 devises, mais attention aux contraintes :
// CHF : montant minimum 0.50 CHF (50 centimes)
if (amountInCents < 50) {
throw new IllegalArgumentException("Minimum amount is 0.50 CHF");
}
// USD : montant minimum 0.50 USD (50 cents)
// EUR : montant minimum 0.50 EUR (50 centimes)
Monitoring et logging
Endpoint de santé
@RestController
@RequestMapping("/api/health")
public class HealthController {
private final OrganizerRepository organizerRepository;
private final OrderRepository orderRepository;
@GetMapping
public Map<String, Object> health() {
return Map.of(
"status", "ok",
"timestamp", LocalDateTime.now(),
"organizers", Map.of(
"total", organizerRepository.count(),
"active", organizerRepository.countByOnboardingComplete(true)
),
"orders", Map.of(
"total", orderRepository.count(),
"pending", orderRepository.countByStatus("pending"),
"completed", orderRepository.countByStatus("completed"),
"failed", orderRepository.countByStatus("failed")
)
);
}
}
Logs structurés
log.info("Creating payment intent - " +
"Event: {}, Amount: {} CHF, Organizer: {}, Platform Fee: {} CHF",
event.getId(),
request.getAmount(),
organizer.getId(),
platformFee / 100.0
);
Architecture Docker
# docker-compose.yml
services:
app:
build: .
ports:
- "8080:8080"
environment:
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
- POSTGRES_HOST=db
depends_on:
- db
db:
image: postgres:15
environment:
- POSTGRES_DB=standup
- POSTGRES_USER=standup
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
volumes:
- ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql
Résultats après 2 mois en production
Métriques techniques
- Onboarding : 94% de réussite (abandon à l’étape KYC)
- Paiements : 99.2% de succès
- Webhooks : 100% reçus (aucune perte)
- Latence API : moyenne 180ms
Métriques business
- 47 organisateurs actifs
- 2,340 billets vendus
- 186,500 CHF de volume
- 14,920 CHF de commissions
Économies vs développement custom
- Pas de licence PSP : ~15,000 CHF/an économisés
- Conformité PCI automatique
- Support Stripe 24/7
Documentation et OpenAPI
J’ai ajouté Springdoc pour une doc API automatique :
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("standup.myticketing.ch API")
.version("1.0.0")
.description("Backend Stripe Connect pour billetterie")
)
.servers(List.of(
new Server()
.url("http://localhost:8080")
.description("Dev"),
new Server()
.url("https://api.standup.myticketing.ch")
.description("Production")
));
}
}
Accessible sur http://localhost:8080/swagger-ui.html
Pour aller plus loin
Fonctionnalités avancées
Subscriptions : abonnements mensuels pour organisateurs premium
Subscription subscription = Subscription.create(Map.of(
"customer", customerId,
"items", List.of(Map.of("price", priceId)),
"application_fee_percent", 15.0 // Commission 15%
));
Refunds : remboursements avec répartition automatique
Refund refund = Refund.create(Map.of(
"payment_intent", paymentIntentId,
"reverse_transfer", true // Récupère aussi la commission
));
Express Dashboard : dashboard Stripe pour les organisateurs
LoginLink link = LoginLink.create(
accountId,
Map.of("redirect_url", frontendUrl + "/dashboard")
);
Conclusion
Stripe Connect avec Spring Boot, c’est 3 composants clés :
- Onboarding : création de comptes Express
- Payment Intents : paiements avec
application_fee_amount - Webhooks : synchronisation des états
Use cases parfaits :
- Marketplaces (billetterie, location, services)
- Plateformes SaaS multi-tenants
- Crowdfunding / financement participatif
À éviter si :
- Volume < 10 transactions/mois (coûts fixes Stripe)
- Besoin de gestion manuelle des fonds
- Pays non supportés par Connect
Le code complet est sur mon GitHub @fabiomeyer.
Des questions sur Stripe Connect ou Spring Boot ? Ping-moi sur Twitter !