Skip to content
Blog
Backend9 min read

Designing Idempotent Payment Webhooks in Spring Boot

B

BADJO Dibéa Koffi

Published on May 17, 2026

The Webhook Problem

Payment providers retry webhooks until they get a 200. Stripe retries up to 15 times over 3 days. A retry means double-charging, double-decrementing inventory, and duplicate emails.

Rule 1: Verify the Signature

@PostMapping("/webhooks/stripe")
public ResponseEntity<Void> handleStripe(
        @RequestBody String payload,
        @RequestHeader("Stripe-Signature") String signature) {
    Event event = Webhook.constructEvent(payload, signature, webhookSecret);
    processEvent(event);
    return ResponseEntity.ok().build();
}

Rule 2: Return 200 Before Processing

webhookEventRepo.save(new WebhookEvent(event.getId(), "stripe", payload, PENDING));
eventProcessor.processAsync(event.getId());
return ResponseEntity.ok().build();

Rule 3: Idempotency Key

@Transactional
public void processEvent(String eventId) {
    var existing = webhookEventRepo.findByExternalId(eventId);
    if (existing.isPresent() && existing.get().getStatus() == PROCESSED) {
        return; // Already handled
    }
    doProcess(existing.orElseThrow());
}

Rule 4: Reconciliation

Webhooks can be lost. Run a daily reconciliation:

@Scheduled(cron = "0 0 3 * * *")
public void reconcilePayments() {
    var pending = orderRepo.findByStatusAndCreatedBefore(
        PENDING_PAYMENT, Instant.now().minus(Duration.ofHours(2))
    );
    for (var order : pending) {
        var intent = stripe.paymentIntents().retrieve(order.getStripePaymentId());
        if ("succeeded".equals(intent.getStatus())) {
            fulfillOrder(intent);
        }
    }
}

The Complete Pattern

  1. Receive webhook, verify signature
  2. Store the raw event with unique external ID
  3. Return 200 immediately
  4. Process asynchronously with idempotency check
  5. Reconcile daily for missed events
spring-bootwebhookspaymentsidempotency
Share

Comments