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
- Vérification de signature (sécurité)
- Idempotence (éviter duplicates)
- Retry automatique (fiabilité)
- Logging structuré (debug)
- 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 :
- Network timeout → retry automatique
- Votre endpoint répond lentement (> 30s)
- Bug temporaire côté Stripe
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 :
- Répond avec status code ≠ 2xx
- Timeout (> 30 secondes)
- Ne répond pas (serveur down)
→ 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 :
- Serveur redémarre → webhooks perdus pendant 10-30s
- Stripe retry → mais délai de 1h
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 :
- Blue (v1.2) tourne
- Démarrer Green (v1.3)
- Nginx balance entre les deux
- Vérifier Green OK
- 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)
- 12% de webhooks perdus
- 8-15 minutes de délai avant détection
- 3 incidents majeurs en 2 semaines
Après (architecture robuste)
- 0% de webhooks perdus
- 100% d’idempotence respectée
- Durée moyenne traitement : 87ms
- Aucun incident en 6 semaines
Chiffres
- 14,231 webhooks reçus
- 14,231 webhooks traités (100%)
- 6 doublons détectés et ignorés
- 0 faux positifs (signature invalide)
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 :
- Vérification de signature (sécurité)
- Idempotence (éviter duplicates)
- Retry automatique (fiabilité)
- Logging structuré (debug)
- Monitoring (alertes)
Use cases critiques :
- Paiements e-commerce
- Abonnements SaaS
- Marketplaces (Stripe Connect)
Ce qui peut mal tourner :
- Webhooks perdus → commandes bloquées
- Duplicates non gérés → double facturation
- Pas de monitoring → incidents silencieux
Le code complet est sur mon GitHub @fabiomeyer.
Questions sur l’architecture de webhooks ? Contactez-moi sur Twitter !