← Retour au blog

Architecture de webhooks Stripe en production : sécurité, idempotence et monitoring

Comment j'ai construit un système de webhooks robuste pour gérer les événements Stripe avec Spring Boot. Retour sur les pièges et les solutions éprouvées.

Le problème avec les webhooks

Janvier 2026, 3h du matin. Mon téléphone sonne : alerte PagerDuty.

“250 commandes en status ‘pending’ depuis 2h. Aucune n’a été marquée ‘completed’.”

J’ouvre Stripe Dashboard : tous les paiements ont réussi. J’ouvre ma base de données : toutes les commandes sont en ‘pending’.

Le problème : mes webhooks Stripe n’étaient pas traités.

Ce qui devait être une simple intégration est devenu un cauchemar de production. Voici comment j’ai corrigé le tir et construit un système robuste de gestion de webhooks avec Spring Boot.

Pourquoi les webhooks sont critiques

Le flux de paiement complet

1. Frontend crée un PaymentIntent    → status: "requires_payment_method"
2. Client entre sa carte              → status: "requires_confirmation"
3. Client confirme                    → status: "processing"
4. Banque approuve (asynchrone!)      → status: "succeeded"

Le piège : l’étape 4 peut prendre de quelques secondes à plusieurs minutes (3D Secure, vérifications bancaires).

Sans webhook : votre app ne sait jamais que le paiement a réussi.

Ce que j’ai appris à mes dépens

Erreur #1 : Faire confiance à la réponse synchrone de PaymentIntent.create()

// ❌ Code naïf (ne fonctionne pas)
@PostMapping("/create-payment")
public PaymentResponse createPayment(@RequestBody PaymentRequest request) {
    PaymentIntent intent = PaymentIntent.create(params);
    
    if (intent.getStatus().equals("succeeded")) {
        order.setStatus("completed");  // Ne sera JAMAIS appelé!
    }
    
    return new PaymentResponse(intent.getClientSecret());
}

intent.getStatus() retourne "requires_confirmation" ou "processing", jamais "succeeded" immédiatement.

Solution : toujours utiliser les webhooks.

Architecture robuste de webhooks

Les 5 piliers

  1. Vérification de signature (sécurité)
  2. Idempotence (éviter duplicates)
  3. Retry automatique (fiabilité)
  4. Logging structuré (debug)
  5. Monitoring (alertes)

Pilier #1 : Vérification de signature

Pourquoi c’est critique

Sans vérification, n’importe qui peut envoyer un faux webhook vers votre endpoint :

# Attaque triviale
curl -X POST https://votreapp.ch/api/webhooks/stripe \
  -H "Content-Type: application/json" \
  -d '{"type":"payment_intent.succeeded","data":{"object":{"id":"fake"}}}'

→ Votre app marque une commande comme payée alors qu’elle ne l’est pas.

Implémentation sécurisée

@RestController
@RequestMapping("/api/webhooks")
@Slf4j
public class WebhookController {
    
    @Value("${stripe.webhook-secret}")
    private String webhookSecret;
    
    @PostMapping("/stripe")
    public ResponseEntity<String> handleStripeWebhook(
        @RequestBody String payload,
        @RequestHeader("Stripe-Signature") String sigHeader
    ) {
        
        Event event;
        
        try {
            // Vérification cryptographique de la signature
            event = Webhook.constructEvent(
                payload, 
                sigHeader, 
                webhookSecret
            );
        } catch (SignatureVerificationException e) {
            log.error("⚠️  Webhook signature verification failed: {}", 
                e.getMessage());
            return ResponseEntity.status(400).body("Invalid signature");
        }
        
        // Traiter l'événement...
        processEvent(event);
        
        return ResponseEntity.ok("OK");
    }
}

Obtenir le webhook secret

# En dev : Stripe CLI
stripe listen --forward-to localhost:8080/api/webhooks/stripe

# Affiche :
# > Ready! Your webhook signing secret is whsec_abc123...

# En production : Stripe Dashboard
# Developers → Webhooks → Add endpoint
# URL: https://votreapp.ch/api/webhooks/stripe
# Events: payment_intent.succeeded, payment_intent.payment_failed, etc.
# application.yml
stripe:
  webhook-secret: ${STRIPE_WEBHOOK_SECRET}

Pilier #2 : Idempotence

Le problème des doublons

Stripe peut renvoyer le même webhook plusieurs fois :

Scenario catastrophe sans idempotence

1. Webhook payment_intent.succeeded reçu
2. Commande marquée "completed"
3. Email de confirmation envoyé
4. Stripe renvoie le MÊME webhook
5. Email RE-envoyé au client (😱)

Solution : table de tracking des événements

// entity/ProcessedWebhookEvent.java
@Entity
@Table(
    name = "processed_webhook_events",
    indexes = @Index(name = "idx_stripe_event_id", columnList = "stripe_event_id")
)
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ProcessedWebhookEvent {
    
    @Id
    @GeneratedValue(strategy = GenerationType.UUID)
    private UUID id;
    
    @Column(name = "stripe_event_id", unique = true, nullable = false)
    private String stripeEventId;
    
    @Column(name = "event_type", nullable = false)
    private String eventType;
    
    @Column(name = "processed_at", nullable = false)
    private LocalDateTime processedAt;
    
    @Column(name = "processing_duration_ms")
    private Long processingDurationMs;
    
    @Column(name = "success")
    private Boolean success;
    
    @Column(name = "error_message", length = 1000)
    private String errorMessage;
}

Controller avec idempotence

@PostMapping("/stripe")
public ResponseEntity<String> handleStripeWebhook(
    @RequestBody String payload,
    @RequestHeader("Stripe-Signature") String sigHeader
) {
    
    // 1. Vérifier signature
    Event event;
    try {
        event = Webhook.constructEvent(payload, sigHeader, webhookSecret);
    } catch (SignatureVerificationException e) {
        log.error("Invalid webhook signature");
        return ResponseEntity.status(400).body("Invalid signature");
    }
    
    // 2. Idempotence check
    if (processedEventRepository.existsByStripeEventId(event.getId())) {
        log.info("✓ Event {} already processed, skipping", event.getId());
        return ResponseEntity.ok("Already processed");
    }
    
    // 3. Traiter l'événement
    long startTime = System.currentTimeMillis();
    boolean success = false;
    String errorMessage = null;
    
    try {
        processEvent(event);
        success = true;
    } catch (Exception e) {
        log.error("Error processing webhook event {}: {}", 
            event.getId(), e.getMessage(), e);
        errorMessage = e.getMessage();
    }
    
    // 4. Sauvegarder le tracking
    long duration = System.currentTimeMillis() - startTime;
    
    ProcessedWebhookEvent processed = new ProcessedWebhookEvent(
        null,
        event.getId(),
        event.getType(),
        LocalDateTime.now(),
        duration,
        success,
        errorMessage
    );
    
    processedEventRepository.save(processed);
    
    if (!success) {
        return ResponseEntity.status(500).body("Processing error");
    }
    
    log.info("✓ Event {} processed successfully in {}ms", 
        event.getId(), duration);
    
    return ResponseEntity.ok("OK");
}

Avantages

✅ Garantit qu’un événement est traité exactement une fois
✅ Permet de debugger (table d’audit complète)
✅ Mesure les performances (durée de traitement)
✅ Détecte les échecs (field success)

Pilier #3 : Retry automatique (côté Stripe)

Comment Stripe retry

Si votre endpoint :

→ Stripe retry automatiquement avec backoff exponentiel :

Tentative 1 : immédiat
Tentative 2 : +1 heure
Tentative 3 : +6 heures
Tentative 4 : +12 heures
...jusqu'à 3 jours

Optimiser pour les retries

❌ Mauvais code

@PostMapping("/stripe")
public ResponseEntity<String> handleWebhook(...) {
    Event event = Webhook.constructEvent(...);
    
    // Traitement long (10 secondes)
    sendEmailToCustomer(event);
    updateAnalytics(event);
    notifySlack(event);
    
    return ResponseEntity.ok("OK");  // Timeout si > 30s !
}

✅ Bon code : traitement asynchrone

@Service
public class WebhookService {
    
    private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
    
    public void processEventAsync(Event event) {
        // Répondre immédiatement à Stripe
        executor.submit(() -> {
            try {
                // Traitement long en arrière-plan
                processEvent(event);
            } catch (Exception e) {
                log.error("Error processing event async: {}", e.getMessage());
            }
        });
    }
}
@PostMapping("/stripe")
public ResponseEntity<String> handleWebhook(...) {
    Event event = Webhook.constructEvent(...);
    
    // Idempotence check...
    
    // Traitement asynchrone
    webhookService.processEventAsync(event);
    
    // Réponse immédiate (< 100ms)
    return ResponseEntity.ok("OK");
}

Bonus Java 21 : Executors.newVirtualThreadPerTaskExecutor() pour des millions de tâches concurrentes.

Pilier #4 : Routing d’événements

Gérer plusieurs types d’événements

private void processEvent(Event event) {
    switch (event.getType()) {
        case "payment_intent.succeeded":
            handlePaymentSuccess(event);
            break;
        
        case "payment_intent.payment_failed":
            handlePaymentFailure(event);
            break;
        
        case "payment_intent.canceled":
            handlePaymentCanceled(event);
            break;
        
        case "account.updated":
            handleAccountUpdate(event);
            break;
        
        case "charge.refunded":
            handleRefund(event);
            break;
        
        default:
            log.info("Unhandled event type: {}", event.getType());
    }
}

Handlers spécifiques

private void handlePaymentSuccess(Event event) {
    PaymentIntent intent = deserialize(event, PaymentIntent.class);
    
    log.info("💰 Payment succeeded: {} ({})", 
        intent.getId(), 
        formatAmount(intent.getAmount(), intent.getCurrency())
    );
    
    // Récupérer la commande
    Order order = orderRepository
        .findByStripePaymentIntentId(intent.getId())
        .orElseThrow(() -> new NotFoundException(
            "Order not found for PaymentIntent " + intent.getId()
        ));
    
    // Mise à jour atomique
    order.setStatus("completed");
    order.setPaidAt(LocalDateTime.now());
    order.setStripeChargeId(intent.getLatestCharge());
    orderRepository.save(order);
    
    // Actions secondaires (async)
    emailService.sendConfirmation(order);
    analyticsService.trackConversion(order);
    
    log.info("✓ Order {} marked as completed", order.getId());
}

private void handlePaymentFailure(Event event) {
    PaymentIntent intent = deserialize(event, PaymentIntent.class);
    
    String reason = Optional.ofNullable(intent.getLastPaymentError())
        .map(PaymentIntent.Error::getMessage)
        .orElse("Unknown error");
    
    log.warn("❌ Payment failed: {} - Reason: {}", intent.getId(), reason);
    
    Order order = orderRepository
        .findByStripePaymentIntentId(intent.getId())
        .orElseThrow();
    
    order.setStatus("failed");
    order.setFailureReason(reason);
    orderRepository.save(order);
    
    // Notifier le client
    emailService.sendPaymentFailureNotification(order, reason);
    
    log.info("✓ Order {} marked as failed", order.getId());
}

private <T> T deserialize(Event event, Class<T> clazz) {
    return (T) event.getDataObjectDeserializer()
        .getObject()
        .orElseThrow(() -> new IllegalStateException("Cannot deserialize event"));
}

private String formatAmount(Long amountCents, String currency) {
    double amount = amountCents / 100.0;
    return String.format("%.2f %s", amount, currency.toUpperCase());
}

Pilier #5 : Monitoring et alertes

Métriques essentielles

@Service
public class WebhookMetricsService {
    
    private final MeterRegistry meterRegistry;
    
    public void recordWebhookReceived(String eventType) {
        meterRegistry.counter("webhook.received", 
            "event_type", eventType
        ).increment();
    }
    
    public void recordWebhookProcessed(String eventType, boolean success, long durationMs) {
        meterRegistry.counter("webhook.processed", 
            "event_type", eventType,
            "success", String.valueOf(success)
        ).increment();
        
        meterRegistry.timer("webhook.processing.duration",
            "event_type", eventType
        ).record(durationMs, TimeUnit.MILLISECONDS);
    }
    
    public void recordWebhookError(String eventType, String errorType) {
        meterRegistry.counter("webhook.error",
            "event_type", eventType,
            "error_type", errorType
        ).increment();
    }
}

Dashboard de santé

@RestController
@RequestMapping("/api/admin")
public class AdminController {
    
    private final ProcessedWebhookEventRepository webhookRepo;
    private final OrderRepository orderRepository;
    
    @GetMapping("/webhook-health")
    public Map<String, Object> getWebhookHealth() {
        LocalDateTime last24h = LocalDateTime.now().minusHours(24);
        
        long totalProcessed = webhookRepo.countByProcessedAtAfter(last24h);
        long successCount = webhookRepo.countByProcessedAtAfterAndSuccessTrue(last24h);
        long failureCount = totalProcessed - successCount;
        
        double successRate = totalProcessed > 0 
            ? (successCount * 100.0 / totalProcessed) 
            : 0;
        
        long pendingOrders = orderRepository.countByStatus("pending");
        
        return Map.of(
            "period", "last_24h",
            "webhooks_processed", totalProcessed,
            "success_count", successCount,
            "failure_count", failureCount,
            "success_rate_percent", Math.round(successRate * 100) / 100.0,
            "pending_orders", pendingOrders,
            "status", pendingOrders > 100 ? "CRITICAL" : "OK"
        );
    }
    
    @GetMapping("/webhook-failures")
    public List<ProcessedWebhookEvent> getRecentFailures() {
        return webhookRepo.findTop50BySuccessFalseOrderByProcessedAtDesc();
    }
}

Alertes PagerDuty / Slack

@Component
public class WebhookHealthCheck {
    
    @Scheduled(fixedRate = 300000)  // Toutes les 5 minutes
    public void checkPendingOrders() {
        long pendingCount = orderRepository.countByStatus("pending");
        LocalDateTime threshold = LocalDateTime.now().minusMinutes(30);
        long oldPendingCount = orderRepository
            .countByStatusAndCreatedAtBefore("pending", threshold);
        
        if (oldPendingCount > 10) {
            alertService.sendCriticalAlert(
                "🚨 URGENT: " + oldPendingCount + " orders pending for >30min. " +
                "Webhook processing may be broken!"
            );
        }
    }
}

Tests : simuler des webhooks en local

Stripe CLI

# Installation
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Forward vers localhost
stripe listen --forward-to localhost:8080/api/webhooks/stripe

# Dans un autre terminal : trigger un événement
stripe trigger payment_intent.succeeded

# Voir les logs en temps réel

Tests d’intégration avec Testcontainers

@SpringBootTest
@Testcontainers
class WebhookIntegrationTest {
    
    @Container
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
    
    @Autowired
    private MockMvc mockMvc;
    
    @Test
    void shouldProcessPaymentSuccessWebhook() throws Exception {
        String webhookPayload = """
            {
              "id": "evt_test_123",
              "type": "payment_intent.succeeded",
              "data": {
                "object": {
                  "id": "pi_test_456",
                  "amount": 10000,
                  "currency": "chf",
                  "status": "succeeded"
                }
              }
            }
            """;
        
        // Générer signature (simplifiée pour test)
        String signature = generateTestSignature(webhookPayload);
        
        mockMvc.perform(post("/api/webhooks/stripe")
                .contentType(MediaType.APPLICATION_JSON)
                .header("Stripe-Signature", signature)
                .content(webhookPayload))
            .andExpect(status().isOk());
        
        // Vérifier que l'event a été tracké
        assertTrue(processedEventRepo.existsByStripeEventId("evt_test_123"));
    }
}

Stratégie de déploiement sans downtime

Le problème

Si vous déployez une nouvelle version pendant que Stripe envoie des webhooks :

Solution : Blue/Green deployment

# docker-compose.yml
services:
  app-blue:
    image: standup-backend:v1.2.0
    ports:
      - "8080:8080"
  
  app-green:
    image: standup-backend:v1.3.0
    ports:
      - "8081:8080"
  
  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf
# nginx.conf
upstream backend {
    server app-blue:8080;
    server app-green:8080;
}

server {
    listen 80;
    
    location /api/webhooks/stripe {
        proxy_pass http://backend;
        proxy_set_header Host $host;
        proxy_connect_timeout 5s;
        proxy_read_timeout 30s;
    }
}

Déploiement :

  1. Blue (v1.2) tourne
  2. Démarrer Green (v1.3)
  3. Nginx balance entre les deux
  4. Vérifier Green OK
  5. Arrêter Blue

Zero downtime : toujours au moins 1 instance active.

Checklist de production

Sécurité
[ ] Vérification de signature Stripe activée
[ ] Webhook secret stocké en variable d'env (pas en dur)
[ ] HTTPS obligatoire
[ ] Rate limiting sur l'endpoint (max 100 req/min)

Fiabilité
[ ] Table d'idempotence (processed_webhook_events)
[ ] Traitement asynchrone (réponse < 1s à Stripe)
[ ] Retry automatique côté Stripe configuré

Monitoring
[ ] Logs structurés (JSON) avec trace IDs
[ ] Métriques (webhooks processed, errors, duration)
[ ] Alertes si pending orders > seuil
[ ] Dashboard de santé (/api/admin/webhook-health)

Tests
[ ] Tests unitaires sur handlers
[ ] Tests d'intégration avec Testcontainers
[ ] Tests avec Stripe CLI en dev
[ ] Load test (simul 100 webhooks/s)

Déploiement
[ ] Blue/green ou rolling update
[ ] Healthcheck endpoint
[ ] Rollback plan

Erreurs que j’ai faites (pour que vous ne les fassiez pas)

Erreur #1 : Logger les PaymentIntents complets

// ❌ DANGER : expose des données sensibles
log.info("Received PaymentIntent: {}", intent.toString());

Contient des données PCI (derniers chiffres carte, etc.). Ne jamais logger en clair.

Erreur #2 : Ne pas gérer les webhooks de test

En dev, Stripe CLI envoie des événements de test. Sans filtrage :

if (event.getLivemode() == false && !isDevEnvironment()) {
    log.warn("Ignoring test webhook in production");
    return ResponseEntity.ok("Test event ignored");
}

Erreur #3 : Timeout sur traitement long

J’avais un handler qui envoyait 3 emails + update analytics. Durée : 8 secondes.

→ Stripe a timeout (> 5s) et retry → duplicate processing.

Fix : traitement async (voir Pilier #3).

Résultats après 6 semaines en production

Avant (sans idempotence ni monitoring)

Après (architecture robuste)

Chiffres

Pour aller plus loin

Dead Letter Queue

Pour les webhooks qui échouent après tous les retries Stripe :

@Service
public class DeadLetterQueueService {
    
    public void sendToDeadLetterQueue(Event event, Exception error) {
        // SQS, RabbitMQ, ou table DB
        dlqRepository.save(new FailedWebhookEvent(
            event.getId(),
            event.getType(),
            event.toJson(),
            error.getMessage(),
            LocalDateTime.now()
        ));
        
        // Alerte Slack
        slackService.sendAlert("Webhook permanently failed: " + event.getId());
    }
}

Replay manual

Dashboard admin pour rejouer un webhook :

@PostMapping("/admin/replay-webhook/{eventId}")
public ResponseEntity<?> replayWebhook(@PathVariable String eventId) {
    Event event = Event.retrieve(eventId);  // Fetch from Stripe
    processEvent(event);
    return ResponseEntity.ok("Replayed");
}

Conclusion

Les webhooks Stripe, c’est 5 piliers :

  1. Vérification de signature (sécurité)
  2. Idempotence (éviter duplicates)
  3. Retry automatique (fiabilité)
  4. Logging structuré (debug)
  5. Monitoring (alertes)

Use cases critiques :

Ce qui peut mal tourner :

Le code complet est sur mon GitHub @fabiomeyer.

Questions sur l’architecture de webhooks ? Contactez-moi sur Twitter !