← Retour au blog

Intégrer Stripe Connect à Spring Boot : retour sur une plateforme de billetterie

Comment j'ai implémenté Stripe Connect pour gérer les paiements d'une marketplace de billetterie avec Spring Boot 3.2 et Java 21.

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 :

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

Métriques business

Économies vs développement custom

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 :

  1. Onboarding : création de comptes Express
  2. Payment Intents : paiements avec application_fee_amount
  3. Webhooks : synchronisation des états

Use cases parfaits :

À éviter si :

Le code complet est sur mon GitHub @fabiomeyer.

Des questions sur Stripe Connect ou Spring Boot ? Ping-moi sur Twitter !