I learned about payment processing the hard way: losing $2,400 in one afternoon.
It was a Tuesday. Our payment endpoint crashed during high traffic. Nothing catastrophic—just a brief outage. But here's what happened: the Stripe webhook failed to confirm 47 transactions. Our system, confused, retried them all. Customers got charged multiple times. Our support team spent 6 hours refunding people manually.
That's when I realized: payment processing isn't just another API endpoint. One mistake can cost thousands. One design flaw can cascade into compliance violations. One naive implementation can destroy trust.
This article shows you how to build a payment system in Spring Boot that actually scales without bankrupting you or your customers.
The Payment Processing Trap
Most developers treat payment APIs like regular HTTP calls. Get a Stripe API key, call their API, return a response. Done.
This works for your first 100 transactions. Then it falls apart.
Here's why:
The problems nobody tells you about:
-
Network failures happen between your code and Stripe. Did the charge succeed? You don't know. Retry it? Now you might charge twice.
-
Webhooks are unreliable. Stripe tries to notify you, but if your server is down, the notification is lost. Retry the charge? You don't know if you already charged them.
-
Refunds have their own failure states. You processed the charge. Customer requested a refund. Your refund API call fails. Now you owe money, but the refund isn't recorded.
-
PCI compliance means you can't store credit cards. So you must tokenize. But tokens expire. They get revoked. You need to handle token failures gracefully.
-
Payment processing at scale is expensive. Every API call to Stripe costs money (in latency, in failures, in retries). Naive implementations multiply costs unnecessarily.
-
Race conditions destroy everything. Two webhook deliveries arrive simultaneously. Two refund requests hit at once. Database constraints save you sometimes, but not always.
The naive solution fails silently until money is actually lost.
Let me show you how to avoid that.
The Architecture That Doesn't Lose Money
Here's the pattern that works:
Customer Request
↓
Create Order (in DB, status: PENDING)
↓
Charge Payment (via Stripe with Idempotency Key)
↓
Receive Response (success/failure/unknown)
↓
Update Order (in DB, status: PAID/FAILED/PENDING_CONFIRMATION)
↓
Listen for Webhook (Stripe confirms final status)
↓
Update Order (in DB, status: CONFIRMED)
↓
Return to Customer
The key insight: The database is your source of truth. Stripe is just a tool.
Let's build this.
Step 1: Design Your Payment Data Model
@Entity
@Table(name = "orders")
public class Order {
@Id
private String orderId;
private Long userId;
private BigDecimal amount;
@Enumerated(EnumType.STRING)
private OrderStatus status; // PENDING, PAID, FAILED, REFUND_REQUESTED, REFUNDED
// The payment processor reference (Stripe charge ID)
private String stripeChargeId;
// Idempotency key - prevents duplicate charges
private String idempotencyKey;
// For tracking when payment was attempted
private LocalDateTime paymentAttemptedAt;
// For tracking webhooks
private LocalDateTime webhookConfirmedAt;
@Version
private Long version; // Optimistic locking for race conditions
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
enum OrderStatus {
PENDING, // Order created, awaiting payment
PAID, // Payment succeeded (from our perspective)
FAILED, // Payment definitively failed
REFUND_REQUESTED, // Customer requested refund
REFUNDED, // Refund completed
WEBHOOK_PENDING // Webhook confirmation awaited
}
Why this design matters:
- idempotencyKey: Stripe uses this to prevent duplicate charges. Even if your code retries, Stripe returns the same charge ID.
- status field: Your single source of truth. Never rely on Stripe alone.
- version field: Optimistic locking. If two requests update simultaneously, one fails. You handle the retry.
- timestamps: Debug trail. You can see exactly when things happened.
Step 2: Create the Payment Service
This is where the real logic lives:
@Service
public class PaymentService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private StripeClient stripeClient;
@Autowired
private PaymentAuditService auditService;
/**
* Process a payment with proper idempotency and error handling
* Returns the charge ID or throws an exception
*/
public String processPayment(String orderId, Long userId, BigDecimal amount) {
// Step 1: Load the order
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
// Step 2: Check if we already attempted this payment
if (order.getStatus() != OrderStatus.PENDING) {
// Payment already attempted - return cached result or fail
if (order.getStripeChargeId() != null) {
return order.getStripeChargeId();
}
throw new PaymentAlreadyProcessedException(orderId);
}
// Step 3: Generate idempotency key (or use existing one)
String idempotencyKey = generateIdempotencyKey(orderId);
order.setIdempotencyKey(idempotencyKey);
// Step 4: Attempt payment with Stripe
String chargeId;
try {
chargeId = stripeClient.chargeCard(
amount,
userId,
idempotencyKey // CRITICAL: Pass idempotency key
);
} catch (StripeException e) {
// Payment failed at Stripe
order.setStatus(OrderStatus.FAILED);
order.setPaymentAttemptedAt(LocalDateTime.now());
orderRepository.save(order);
auditService.logPaymentFailure(orderId, e.getMessage());
throw new PaymentFailedException(e.getMessage());
}
// Step 5: Update order in database
order.setStripeChargeId(chargeId);
order.setStatus(OrderStatus.WEBHOOK_PENDING); // Await webhook confirmation
order.setPaymentAttemptedAt(LocalDateTime.now());
try {
orderRepository.save(order);
} catch (OptimisticLockingFailureException e) {
// Race condition: Another request updated this order simultaneously
// This is fine - reload and check status
Order reloaded = orderRepository.findById(orderId).get();
if (reloaded.getStripeChargeId() != null) {
return reloaded.getStripeChargeId();
}
throw e;
}
auditService.logPaymentInitiated(orderId, chargeId);
return chargeId;
}
/**
* Handle Stripe webhook - confirms payment status
* This is idempotent - receiving the same webhook twice is safe
*/
public void handleStripeWebhook(StripeWebhookEvent event) {
String chargeId = event.getChargeId();
String status = event.getStatus(); // "succeeded" or "failed"
Order order = orderRepository.findByStripeChargeId(chargeId)
.orElseThrow(() -> new UnknownChargeException(chargeId));
// Webhook may arrive multiple times - idempotent logic
if (order.getWebhookConfirmedAt() != null) {
// Already processed this webhook
return;
}
// Update status based on Stripe's final word
if ("succeeded".equals(status)) {
order.setStatus(OrderStatus.PAID);
} else if ("failed".equals(status)) {
order.setStatus(OrderStatus.FAILED);
}
order.setWebhookConfirmedAt(LocalDateTime.now());
orderRepository.save(order);
auditService.logWebhookProcessed(chargeId, status);
}
/**
* Process a refund - also needs idempotency
*/
public String processRefund(String orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
// Check if already refunded
if (order.getStatus() == OrderStatus.REFUNDED) {
return "Already refunded";
}
if (order.getStatus() != OrderStatus.PAID) {
throw new InvalidRefundStateException("Order not in PAID state");
}
// Update status immediately to prevent duplicate refund attempts
order.setStatus(OrderStatus.REFUND_REQUESTED);
orderRepository.save(order);
try {
String refundId = stripeClient.refundCharge(
order.getStripeChargeId(),
generateRefundIdempotencyKey(orderId) // Prevent duplicate refunds
);
order.setStatus(OrderStatus.REFUNDED);
orderRepository.save(order);
auditService.logRefundProcessed(orderId, refundId);
return refundId;
} catch (StripeException e) {
// Refund failed - order stays in REFUND_REQUESTED
// Retry logic will pick this up
auditService.logRefundFailure(orderId, e.getMessage());
throw new RefundFailedException(e.getMessage());
}
}
private String generateIdempotencyKey(String orderId) {
return "payment_" + orderId + "_" + System.currentTimeMillis();
}
private String generateRefundIdempotencyKey(String orderId) {
return "refund_" + orderId + "_" + System.currentTimeMillis();
}
}
The critical patterns:
- Idempotency key: Every Stripe call includes this. Same key = Stripe returns the same result.
- Database update before webhook: We mark the order as WEBHOOK_PENDING immediately. Webhook is confirmation, not the source of truth.
- Optimistic locking: The
@Versionfield catches race conditions. - Idempotent webhook handling: Receiving the same webhook twice is safe.
- Audit trail: Every action is logged for debugging and compliance.
Step 3: Create the REST Endpoint
@RestController
@RequestMapping("/api/v1/payments")
public class PaymentController {
@Autowired
private PaymentService paymentService;
@PostMapping("/charge")
public ResponseEntity<?> chargeOrder(@RequestBody ChargeRequest request) {
try {
String chargeId = paymentService.processPayment(
request.getOrderId(),
request.getUserId(),
request.getAmount()
);
return ResponseEntity.ok(new ChargeResponse(
chargeId,
"Payment processed. Awaiting confirmation.",
request.getAmount()
));
} catch (PaymentAlreadyProcessedException e) {
return ResponseEntity.status(409)
.body(new ErrorResponse("Payment already processed"));
} catch (PaymentFailedException e) {
return ResponseEntity.status(402)
.body(new ErrorResponse("Payment failed: " + e.getMessage()));
} catch (Exception e) {
return ResponseEntity.status(500)
.body(new ErrorResponse("Internal error"));
}
}
@PostMapping("/refund")
public ResponseEntity<?> refundOrder(@RequestBody RefundRequest request) {
try {
String refundId = paymentService.processRefund(request.getOrderId());
return ResponseEntity.ok(new RefundResponse(
refundId,
"Refund processed"
));
} catch (InvalidRefundStateException e) {
return ResponseEntity.status(400)
.body(new ErrorResponse(e.getMessage()));
} catch (RefundFailedException e) {
return ResponseEntity.status(402)
.body(new ErrorResponse("Refund failed: " + e.getMessage()));
}
}
@PostMapping("/webhook/stripe")
public ResponseEntity<?> handleStripeWebhook(@RequestBody StripeWebhookEvent event) {
// Verify webhook signature (CRITICAL for security)
if (!verifyStripeSignature(event)) {
return ResponseEntity.status(401).build();
}
paymentService.handleStripeWebhook(event);
return ResponseEntity.ok().build();
}
private boolean verifyStripeSignature(StripeWebhookEvent event) {
// Verify using Stripe's signing secret
// This prevents fake webhooks from attacking your system
return true; // Implement actual verification
}
}
Step 4: Handle Failure Recovery
This is where most systems fail:
@Service
public class PaymentRecoveryService {
@Autowired
private OrderRepository orderRepository;
@Autowired
private StripeClient stripeClient;
/**
* Run periodically (via Spring Scheduler) to recover stuck payments
*/
@Scheduled(fixedRate = 60000) // Every 60 seconds
public void recoveryStuckPayments() {
// Find orders stuck in WEBHOOK_PENDING (payment initiated but webhook never arrived)
List<Order> stuckOrders = orderRepository.findByStatusAndPaymentAttemptedAtBefore(
OrderStatus.WEBHOOK_PENDING,
LocalDateTime.now().minusMinutes(5) // Stuck for more than 5 minutes
);
for (Order order : stuckOrders) {
try {
// Query Stripe for this charge's actual status
StripeCharge stripeCharge = stripeClient.getChargeStatus(
order.getStripeChargeId()
);
// Update based on Stripe's reality
if ("succeeded".equals(stripeCharge.getStatus())) {
order.setStatus(OrderStatus.PAID);
order.setWebhookConfirmedAt(LocalDateTime.now());
orderRepository.save(order);
} else if ("failed".equals(stripeCharge.getStatus())) {
order.setStatus(OrderStatus.FAILED);
orderRepository.save(order);
}
} catch (Exception e) {
// Can't reach Stripe - try again next cycle
log.error("Failed to recover payment for order: " + order.getId(), e);
}
}
}
/**
* Retry failed refunds
*/
@Scheduled(fixedRate = 120000) // Every 2 minutes
public void retryFailedRefunds() {
List<Order> pendingRefunds = orderRepository.findByStatus(
OrderStatus.REFUND_REQUESTED
);
for (Order order : pendingRefunds) {
try {
String refundId = stripeClient.refundCharge(
order.getStripeChargeId(),
"retry_" + order.getId()
);
order.setStatus(OrderStatus.REFUNDED);
orderRepository.save(order);
} catch (Exception e) {
// Will retry next cycle
log.warn("Refund retry failed for order: " + order.getId());
}
}
}
}
Real-World Costs & Scalability Issues
Here's what nobody tells you:
Stripe charges you for:
- Every API call (minimal, but adds up)
- Processing fees (2.9% + $0.30 per transaction)
- Failed charges (still charged)
- Webhook retries (free, but your server pays)
Common mistakes that cost money:
-
Retry storms: One failed charge, and your code retries 100 times in 1 second. $30 down the drain.
- Fix: Exponential backoff. First retry in 1s, then 5s, then 30s, then 300s.
-
Duplicate charges: Race condition hits. Same transaction charged twice.
- Fix: Idempotency keys (shown above) + database constraints.
-
Lost refunds: Refund initiated but webhook lost. You owe money, system doesn't know.
- Fix: Refund recovery service (shown above).
-
Webhook bottleneck: All webhooks processed synchronously. Stripe times out and retries.
- Fix: Queue webhooks asynchronously (RabbitMQ, Kafka, or even simple database polling).
-
Insufficient timeout handling: Charge API times out. Did it go through? Retry and charge twice.
- Fix: Wait longer before retrying. Call Stripe to check status first.
Security: Don't Lose the Keys
Payment systems attract attackers. Here's the minimum:
@Configuration
public class StripeSecurityConfig {
// NEVER hardcode Stripe keys
@Value("${stripe.api.key}")
private String stripeApiKey;
@Value("${stripe.webhook.secret}")
private String webhookSecret;
/**
* Verify webhook signature before processing
*/
public boolean verifyWebhookSignature(String payload, String signature) {
try {
Crypto.verifySignatureHeader(
payload,
signature,
webhookSecret
);
return true;
} catch (SignatureVerificationException e) {
log.warn("Invalid webhook signature - potential attack");
return false;
}
}
/**
* Use secrets manager (AWS Secrets Manager, HashiCorp Vault)
*/
@Bean
public Stripe configureStripe() {
// Load from environment/secrets manager, NEVER hardcode
Stripe.apiKey = stripeApiKey;
return Stripe.apiVersion("2023-10-16");
}
}
In your application.yml:
stripe:
api:
key: ${STRIPE_API_KEY} # Load from environment variable
webhook:
secret: ${STRIPE_WEBHOOK_SECRET}
PCI Compliance (the bare minimum):
- ✅ Never store raw credit cards
- ✅ Use Stripe Tokens instead
- ✅ Encrypt sensitive data in transit (HTTPS only)
- ✅ Audit logging (shown above)
- ✅ Regular penetration testing
How to Test This (Without Losing Real Money)
@SpringBootTest
public class PaymentServiceTest {
@Autowired
private PaymentService paymentService;
@MockBean
private StripeClient stripeClient;
@Test
public void testSuccessfulPaymentCreatesOrder() {
// Arrange
when(stripeClient.chargeCard(any(), any(), any()))
.thenReturn("charge_123");
// Act
String chargeId = paymentService.processPayment(
"order_1",
123L,
BigDecimal.valueOf(99.99)
);
// Assert
assertEquals("charge_123", chargeId);
// Verify order was created in WEBHOOK_PENDING state
Order order = orderRepository.findById("order_1").get();
assertEquals(OrderStatus.WEBHOOK_PENDING, order.getStatus());
}
@Test
public void testIdempotencyPrevents DuplicateCharges() {
// First call succeeds
when(stripeClient.chargeCard(any(), any(), any()))
.thenReturn("charge_123");
String chargeId1 = paymentService.processPayment("order_1", 123L, BigDecimal.TEN);
// Second call with same order should return same charge ID
String chargeId2 = paymentService.processPayment("order_1", 123L, BigDecimal.TEN);
assertEquals(chargeId1, chargeId2);
// Verify chargeCard was only called once (not twice)
verify(stripeClient, times(1)).chargeCard(any(), any(), any());
}
@Test
public void testWebhookConfirmsPayment() {
// First: Create pending payment
when(stripeClient.chargeCard(any(), any(), any()))
.thenReturn("charge_123");
paymentService.processPayment("order_1", 123L, BigDecimal.TEN);
// Then: Receive webhook confirming it succeeded
StripeWebhookEvent event = new StripeWebhookEvent();
event.setChargeId("charge_123");
event.setStatus("succeeded");
paymentService.handleStripeWebhook(event);
// Verify order is now PAID
Order order = orderRepository.findById("order_1").get();
assertEquals(OrderStatus.PAID, order.getStatus());
}
}
The Money Reality
Building this system took me 2 weeks initially.
Debugging why $4,200 vanished in test transactions took me 3 days.
Discovering our refund webhook never fired, leaving us with $8,000 in pending refunds took me 1 month (because I wasn't monitoring it properly).
What it cost us in learning:
- Lost time: 40 hours
- Test transactions: ~$400
- Refunded customer charges: ~$2,400
- Actual time value: ~$8,000
What it saved us in the long run:
- Never lost another customer payment
- Zero double-charge incidents
- 99.99% webhook confirmation rate
- Confident scaling to 100k+ transactions/month
The Lessons
-
Payment processing is not optional. If you get this wrong, you lose money. Not eventually. Immediately.
-
The database is your source of truth. Stripe is a tool, not a ledger.
-
Idempotency is non-negotiable. Every Stripe call must be idempotent. Make it automatic.
-
Webhooks will fail. Build recovery logic. Monitor stuck payments. Alert if > 5 minutes pass without webhook confirmation.
-
Test with real Stripe test keys. Stripe's test environment is accurate. Use it.
-
Audit everything. Every payment action goes to an audit log. You'll need it for debugging and compliance.
-
Secrets management matters. One leaked Stripe key costs more than you'll earn in 6 months.
-
Know your costs. Every retry, every failed charge, every webhook retry has a cost. Calculate it.
Final Code: The Bare Minimum
If you're rushing, here's what you absolutely need:
✅ Idempotency keys on every Stripe call ✅ Database status field (not just Stripe's response) ✅ Webhook signature verification ✅ Audit logging ✅ Refund recovery service ✅ Stuck payment detection ✅ Optimistic locking (to prevent race conditions)
Skip any of these, and you will lose money.
Build this properly now, and you'll thank yourself when you're processing thousands of transactions daily without losing a single one.
Sources & Further Reading: