System Design: Payment Gateway Like Stripe — Spring Boot Implementation
Design and implement a payment gateway from scratch. Covers idempotency, retry logic with exponential backoff, webhook handling, distributed transactions using the saga pattern, payment status state machine, and reconciliation — all with working Spring Boot code.
The Problem
If you’re preparing for system design interviews at fintech companies, payment gateway design is a top-3 question. It tests everything: consistency guarantees, failure handling, distributed state management, and security.
But most articles stop at whiteboard diagrams. In this post, we’ll go deeper — I’ll show you the actual implementation patterns I use in production for a fintech platform in Singapore processing thousands of transactions daily.
We’ll cover:
- High-level architecture
- Payment status state machine
- Idempotency implementation
- Retry logic with exponential backoff
- Webhook processing
- Distributed transactions with the Saga pattern
- Reconciliation
Requirements
Functional Requirements
1 — Process payments (charge, authorize, capture, refund)
2 — Support multiple payment methods (card, bank transfer, wallet)
3 — Idempotent payment processing (same request never charged twice)
4 — Webhook notifications to merchants
5 — Transaction history and reporting
6 — Refund processing (full and partial)
Non-Functional Requirements
1 — Exactly-once payment execution (never double-charge)
2 — 99.99% availability for payment processing
3 — Sub-second latency for payment authorization
4 — Strong consistency for payment state
5 — Audit trail for every state transition
6 — PCI-DSS compliance considerations
High-Level Architecture
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌──────────────────────────────────────────────────────────┐
│ API Gateway / Load Balancer │
└────────────────────────┬─────────────────────────────────┘
│
┌────────────────────────▼─────────────────────────────────┐
│ Payment API Service │
│ (Idempotency check, validation, orchestration) │
└──────┬──────────────┬──────────────────┬─────────────────┘
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌─────────────────┐
│ Payment │ │ Payment │ │ Webhook │
│ Router │ │ State │ │ Dispatcher │
│ │ │ Machine │ │ │
└─────┬──────┘ └────────────┘ └─────────────────┘
│
├──────────────┬──────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Card │ │ Bank │ │ Wallet │
│ Processor│ │ Transfer │ │ Provider │
│ (Stripe) │ │ (Plaid) │ │ (GrabPay)│
└──────────┘ └──────────┘ └──────────┘
Payment Status State Machine
The state machine is the heart of any payment system. Getting this wrong means inconsistent state, double charges, or lost money.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
┌──────────┐
│ INITIATED│
└────┬─────┘
│ validate()
▼
┌──────────┐
┌───── │ PENDING │ ─────┐
│ └────┬─────┘ │
│ │ │ fail()
│ │ authorize() │
│ ▼ ▼
│ ┌──────────┐ ┌────────┐
│ │AUTHORIZED│ │ FAILED │
│ └────┬─────┘ └────────┘
│ │
│ │ capture()
│ ▼
│ ┌──────────┐
│ │ CAPTURED │
│ └────┬─────┘
│ │
│ │ refund()
│ ▼
│ ┌──────────┐
└─────▶│ REFUNDED │
└──────────┘
State Machine Implementation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public enum PaymentStatus {
INITIATED,
PENDING,
AUTHORIZED,
CAPTURED,
FAILED,
REFUNDED,
PARTIALLY_REFUNDED;
private static final Map<PaymentStatus, Set<PaymentStatus>> VALID_TRANSITIONS = Map.of(
INITIATED, Set.of(PENDING, FAILED),
PENDING, Set.of(AUTHORIZED, FAILED),
AUTHORIZED, Set.of(CAPTURED, FAILED, REFUNDED),
CAPTURED, Set.of(REFUNDED, PARTIALLY_REFUNDED),
PARTIALLY_REFUNDED, Set.of(REFUNDED, PARTIALLY_REFUNDED)
);
public boolean canTransitionTo(PaymentStatus target) {
Set<PaymentStatus> allowed = VALID_TRANSITIONS.get(this);
return allowed != null && allowed.contains(target);
}
public PaymentStatus transitionTo(PaymentStatus target) {
if (!canTransitionTo(target)) {
throw new InvalidStateTransitionException(
"Cannot transition from %s to %s".formatted(this, target));
}
return target;
}
}
Payment Entity
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@Entity
@Table(name = "payments")
@EntityListeners(AuditingEntityListener.class)
public class Payment {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
@Column(nullable = false, unique = true)
private String idempotencyKey;
@Column(nullable = false)
private String merchantId;
@Column(nullable = false)
private Long amountInCents;
@Column(nullable = false, length = 3)
private String currency;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private PaymentStatus status;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private PaymentMethod paymentMethod;
private String processorTransactionId;
private String failureReason;
private String failureCode;
@Column(nullable = false)
private Long refundedAmountInCents = 0L;
@CreatedDate
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
@Version
private Long version; // Optimistic locking
@OneToMany(mappedBy = "payment", cascade = CascadeType.ALL)
@OrderBy("createdAt ASC")
private List<PaymentStatusHistory> statusHistory = new ArrayList<>();
public void transitionTo(PaymentStatus newStatus, String reason) {
PaymentStatus previous = this.status;
this.status = previous.transitionTo(newStatus);
// Record history for audit trail
statusHistory.add(new PaymentStatusHistory(
this, previous, newStatus, reason, Instant.now()));
}
}
Idempotency — The Most Critical Piece
Idempotency ensures that if a client retries a payment request (due to timeout, network failure, etc.), the payment is only processed once.
The Idempotency Key Strategy
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Entity
@Table(name = "idempotency_keys")
public class IdempotencyRecord {
@Id
private String idempotencyKey;
@Column(nullable = false)
private String requestHash; // SHA-256 of request body
@Enumerated(EnumType.STRING)
private IdempotencyStatus status; // IN_PROGRESS, COMPLETED, FAILED
@Column(columnDefinition = "jsonb")
private String responseBody;
private Integer responseStatusCode;
private Instant createdAt;
private Instant expiresAt; // TTL for cleanup
@Version
private Long version;
}
public enum IdempotencyStatus {
IN_PROGRESS,
COMPLETED,
FAILED
}
Idempotency Filter
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
@Component
@RequiredArgsConstructor
@Order(1) // Execute before other filters
public class IdempotencyFilter extends OncePerRequestFilter {
private final IdempotencyService idempotencyService;
private final ObjectMapper objectMapper;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String idempotencyKey = request.getHeader("Idempotency-Key");
// Only apply to payment creation endpoints
if (idempotencyKey == null || !isPaymentEndpoint(request)) {
chain.doFilter(request, response);
return;
}
// Read and cache request body
CachedBodyHttpServletRequest cachedRequest = new CachedBodyHttpServletRequest(request);
String requestHash = computeHash(cachedRequest.getBody());
Optional<IdempotencyRecord> existing = idempotencyService
.findByKey(idempotencyKey);
if (existing.isPresent()) {
handleExistingKey(existing.get(), requestHash, response);
return;
}
// Attempt to claim the idempotency key
boolean claimed = idempotencyService.claimKey(idempotencyKey, requestHash);
if (!claimed) {
// Another request is already processing this key
response.setStatus(HttpStatus.CONFLICT.value());
response.getWriter().write(
"{\"error\": \"Request with this idempotency key is already being processed\"}");
return;
}
// Process the request and capture the response
ContentCachingResponseWrapper responseWrapper =
new ContentCachingResponseWrapper(response);
try {
chain.doFilter(cachedRequest, responseWrapper);
// Store the response for future replay
idempotencyService.completeKey(
idempotencyKey,
responseWrapper.getStatus(),
new String(responseWrapper.getContentAsByteArray())
);
responseWrapper.copyBodyToResponse();
} catch (Exception e) {
idempotencyService.failKey(idempotencyKey);
throw e;
}
}
private void handleExistingKey(IdempotencyRecord record,
String requestHash,
HttpServletResponse response) throws IOException {
// Verify the request body matches (prevent misuse of idempotency keys)
if (!record.getRequestHash().equals(requestHash)) {
response.setStatus(HttpStatus.UNPROCESSABLE_ENTITY.value());
response.getWriter().write(
"{\"error\": \"Idempotency key already used with different request body\"}");
return;
}
switch (record.getStatus()) {
case COMPLETED -> {
// Replay the original response
response.setStatus(record.getResponseStatusCode());
response.setContentType("application/json");
response.getWriter().write(record.getResponseBody());
}
case IN_PROGRESS -> {
response.setStatus(HttpStatus.CONFLICT.value());
response.getWriter().write(
"{\"error\": \"Request is still being processed\"}");
}
case FAILED -> {
// Allow retry on failed requests
response.setStatus(HttpStatus.CONFLICT.value());
response.getWriter().write(
"{\"error\": \"Previous attempt failed, please use a new idempotency key\"}");
}
}
}
private String computeHash(byte[] body) {
return Hex.toHexString(
MessageDigest.getInstance("SHA-256").digest(body));
}
}
Database-Level Idempotency (Belt and Suspenders)
Even with the filter, we add database-level protection:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Service
@RequiredArgsConstructor
public class PaymentService {
private final PaymentRepository paymentRepository;
private final PaymentRouter paymentRouter;
private final PaymentEventPublisher eventPublisher;
@Transactional
public PaymentResponse processPayment(PaymentRequest request) {
// Double-check: has this idempotency key been processed?
Optional<Payment> existing = paymentRepository
.findByIdempotencyKey(request.idempotencyKey());
if (existing.isPresent()) {
return toResponse(existing.get());
}
// Create payment record FIRST (marks intent)
Payment payment = Payment.builder()
.idempotencyKey(request.idempotencyKey())
.merchantId(request.merchantId())
.amountInCents(request.amountInCents())
.currency(request.currency())
.paymentMethod(request.paymentMethod())
.status(PaymentStatus.INITIATED)
.build();
payment = paymentRepository.save(payment);
payment.transitionTo(PaymentStatus.PENDING, "Payment initiated");
return toResponse(paymentRepository.save(payment));
}
}
Retry Logic with Exponential Backoff
When calling external payment processors (Stripe, Adyen, etc.), network failures happen. Retry logic must be careful: retry reads, never blindly retry charges.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@Service
@RequiredArgsConstructor
@Slf4j
public class PaymentProcessorClient {
private final RestClient restClient;
private final RetryTemplate retryTemplate;
@Bean
RetryTemplate paymentRetryTemplate() {
return RetryTemplate.builder()
.maxAttempts(3)
.exponentialBackoff(
Duration.ofMillis(500), // initial interval
2.0, // multiplier
Duration.ofSeconds(5) // max interval
)
.retryOn(TransientPaymentException.class)
.notRetryOn(List.of(
PaymentDeclinedException.class, // Don't retry declines
InvalidPaymentRequestException.class, // Don't retry bad requests
FraudDetectedException.class // Don't retry fraud blocks
))
.build();
}
public ProcessorResponse chargePayment(ProcessorChargeRequest request) {
return retryTemplate.execute(context -> {
int attempt = context.getRetryCount() + 1;
log.info("Payment charge attempt {}/3 for idempotency_key={}",
attempt, request.idempotencyKey());
try {
ResponseEntity<ProcessorResponse> response = restClient.post()
.uri("/v1/charges")
.header("Idempotency-Key", request.idempotencyKey())
.body(request)
.retrieve()
.toEntity(ProcessorResponse.class);
return response.getBody();
} catch (HttpClientErrorException e) {
if (e.getStatusCode() == HttpStatus.TOO_MANY_REQUESTS) {
throw new TransientPaymentException("Rate limited", e);
}
if (e.getStatusCode().is4xx()) {
// Client errors are not retryable
throw new InvalidPaymentRequestException(e.getResponseBodyAsString());
}
throw new TransientPaymentException("Processor error", e);
} catch (ResourceAccessException e) {
// Network timeout — retryable
throw new TransientPaymentException("Network timeout", e);
}
});
}
}
Circuit Breaker for Processor Failures
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Service
public class ResilientPaymentRouter {
private final CircuitBreakerRegistry circuitBreakerRegistry;
private final Map<String, PaymentProcessorClient> processors;
public ProcessorResponse route(Payment payment) {
String processorName = resolveProcessor(payment.getPaymentMethod());
CircuitBreaker breaker = circuitBreakerRegistry
.circuitBreaker(processorName);
return breaker.executeSupplier(() ->
processors.get(processorName).chargePayment(toProcessorRequest(payment))
);
}
}
Webhook Handling
Webhooks are how payment processors notify you about asynchronous events (3DS completion, bank transfers, disputes).
Webhook Controller
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@RestController
@RequestMapping("/webhooks")
@RequiredArgsConstructor
@Slf4j
public class WebhookController {
private final WebhookVerificationService verificationService;
private final WebhookProcessingService processingService;
@PostMapping("/stripe")
public ResponseEntity<Void> handleStripeWebhook(
@RequestBody String payload,
@RequestHeader("Stripe-Signature") String signature) {
// 1. Verify signature FIRST (reject forged webhooks)
if (!verificationService.verifyStripeSignature(payload, signature)) {
log.warn("Invalid webhook signature received");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
// 2. Parse the event
WebhookEvent event = parseStripeEvent(payload);
// 3. Idempotent processing (webhooks can be delivered multiple times)
boolean processed = processingService.processWebhook(event);
// 4. Always return 200 to prevent retries (even if we've seen this event)
return ResponseEntity.ok().build();
}
}
Idempotent Webhook Processing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@Service
@RequiredArgsConstructor
@Slf4j
public class WebhookProcessingService {
private final WebhookEventRepository eventRepository;
private final PaymentService paymentService;
@Transactional
public boolean processWebhook(WebhookEvent event) {
// Deduplicate: check if we've already processed this event
if (eventRepository.existsByEventId(event.id())) {
log.info("Webhook event {} already processed, skipping", event.id());
return false;
}
// Record the event (claims processing)
WebhookEventRecord record = WebhookEventRecord.builder()
.eventId(event.id())
.eventType(event.type())
.payload(event.rawPayload())
.receivedAt(Instant.now())
.status(ProcessingStatus.PROCESSING)
.build();
eventRepository.save(record);
try {
// Route to appropriate handler
switch (event.type()) {
case "payment_intent.succeeded" -> handlePaymentSucceeded(event);
case "payment_intent.payment_failed" -> handlePaymentFailed(event);
case "charge.dispute.created" -> handleDisputeCreated(event);
case "charge.refunded" -> handleRefundCompleted(event);
default -> log.info("Unhandled webhook type: {}", event.type());
}
record.setStatus(ProcessingStatus.COMPLETED);
record.setProcessedAt(Instant.now());
eventRepository.save(record);
return true;
} catch (Exception e) {
record.setStatus(ProcessingStatus.FAILED);
record.setErrorMessage(e.getMessage());
eventRepository.save(record);
throw e;
}
}
private void handlePaymentSucceeded(WebhookEvent event) {
String processorTxId = event.data().get("id").toString();
Payment payment = paymentService
.findByProcessorTransactionId(processorTxId)
.orElseThrow(() -> new PaymentNotFoundException(
"No payment found for processor tx: " + processorTxId));
payment.transitionTo(PaymentStatus.CAPTURED, "Payment confirmed by processor");
paymentService.save(payment);
// Notify merchant via their webhook
merchantWebhookDispatcher.dispatch(payment.getMerchantId(),
new PaymentCompletedWebhook(payment));
}
}
Distributed Transactions — The Saga Pattern
A payment flow often spans multiple services: payment processing, inventory reservation, order confirmation, notification. We use the Saga pattern instead of distributed transactions.
Choreography-Based Saga for Payment Flow
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
@Service
@RequiredArgsConstructor
@Slf4j
public class PaymentSagaOrchestrator {
private final PaymentService paymentService;
private final InventoryService inventoryService;
private final OrderService orderService;
private final NotificationService notificationService;
@Transactional
public PaymentSagaResult executeSaga(PaymentSagaRequest request) {
List<SagaStep> completedSteps = new ArrayList<>();
try {
// Step 1: Reserve inventory
InventoryReservation reservation = inventoryService
.reserve(request.items());
completedSteps.add(new SagaStep("INVENTORY_RESERVED", reservation.id()));
// Step 2: Process payment
PaymentResponse payment = paymentService
.processPayment(request.paymentRequest());
completedSteps.add(new SagaStep("PAYMENT_PROCESSED", payment.id()));
// Step 3: Confirm order
OrderConfirmation order = orderService
.confirm(request.orderId(), payment.id());
completedSteps.add(new SagaStep("ORDER_CONFIRMED", order.id()));
// Step 4: Send notification (non-critical, don't fail saga)
try {
notificationService.sendPaymentConfirmation(
request.userId(), payment.id());
} catch (Exception e) {
log.warn("Notification failed, saga continues", e);
}
return PaymentSagaResult.success(payment, order);
} catch (Exception e) {
// Compensate in reverse order
compensate(completedSteps, request);
throw new PaymentSagaFailedException(
"Saga failed at step " + (completedSteps.size() + 1), e);
}
}
private void compensate(List<SagaStep> completedSteps,
PaymentSagaRequest request) {
// Reverse order compensation
Collections.reverse(completedSteps);
for (SagaStep step : completedSteps) {
try {
switch (step.name()) {
case "ORDER_CONFIRMED" ->
orderService.cancelOrder(step.resourceId());
case "PAYMENT_PROCESSED" ->
paymentService.refundPayment(step.resourceId(), "Saga compensation");
case "INVENTORY_RESERVED" ->
inventoryService.releaseReservation(step.resourceId());
}
log.info("Compensated step: {}", step.name());
} catch (Exception e) {
// Log and continue compensation — don't let one failure stop others
log.error("Compensation failed for step: {}", step.name(), e);
// Store for manual reconciliation
deadLetterService.store(step, e);
}
}
}
}
Event-Driven Saga with Kafka
For more complex scenarios, I prefer event-driven sagas:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Service
@RequiredArgsConstructor
@Slf4j
public class PaymentSagaEventHandler {
private final PaymentService paymentService;
private final KafkaTemplate<String, Object> kafkaTemplate;
@KafkaListener(topics = "inventory.reserved")
public void onInventoryReserved(InventoryReservedEvent event) {
log.info("Inventory reserved for order {}, proceeding with payment",
event.orderId());
try {
PaymentResponse payment = paymentService.processPayment(
new PaymentRequest(
event.orderId(),
event.totalAmount(),
event.currency(),
event.paymentMethod(),
UUID.randomUUID().toString() // idempotency key
));
kafkaTemplate.send("payment.completed",
event.orderId(),
new PaymentCompletedEvent(payment.id(), event.orderId()));
} catch (PaymentDeclinedException e) {
// Publish failure event — triggers inventory release
kafkaTemplate.send("payment.failed",
event.orderId(),
new PaymentFailedEvent(event.orderId(), e.getDeclineReason()));
}
}
@KafkaListener(topics = "payment.failed")
public void onPaymentFailed(PaymentFailedEvent event) {
// This triggers inventory compensation
kafkaTemplate.send("inventory.release-requested",
event.orderId(),
new InventoryReleaseRequestedEvent(event.orderId()));
}
}
Reconciliation
No payment system is complete without reconciliation — verifying that your records match the processor’s records.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
@Service
@RequiredArgsConstructor
@Slf4j
public class PaymentReconciliationService {
private final PaymentRepository paymentRepository;
private final ProcessorReconciliationClient processorClient;
private final ReconciliationReportRepository reportRepository;
@Scheduled(cron = "0 0 3 * * *") // Run daily at 3 AM
public void dailyReconciliation() {
LocalDate yesterday = LocalDate.now().minusDays(1);
reconcileDate(yesterday);
}
public ReconciliationReport reconcileDate(LocalDate date) {
log.info("Starting reconciliation for {}", date);
// Get our records
List<Payment> ourPayments = paymentRepository
.findByDateAndStatusIn(date,
List.of(PaymentStatus.CAPTURED, PaymentStatus.REFUNDED));
// Get processor records
List<ProcessorTransaction> processorRecords =
processorClient.getTransactions(date);
ReconciliationReport report = new ReconciliationReport(date);
// Find discrepancies
Map<String, Payment> ourMap = ourPayments.stream()
.collect(Collectors.toMap(
Payment::getProcessorTransactionId, Function.identity()));
Map<String, ProcessorTransaction> theirMap = processorRecords.stream()
.collect(Collectors.toMap(
ProcessorTransaction::id, Function.identity()));
// Check: in our system but not in processor
for (Payment payment : ourPayments) {
if (!theirMap.containsKey(payment.getProcessorTransactionId())) {
report.addDiscrepancy(new Discrepancy(
DiscrepancyType.MISSING_AT_PROCESSOR,
payment.getId().toString(),
payment.getAmountInCents(),
null
));
}
}
// Check: in processor but not in our system
for (ProcessorTransaction tx : processorRecords) {
if (!ourMap.containsKey(tx.id())) {
report.addDiscrepancy(new Discrepancy(
DiscrepancyType.MISSING_IN_OUR_SYSTEM,
null,
null,
tx.amountInCents()
));
}
}
// Check: amount mismatches
for (Payment payment : ourPayments) {
ProcessorTransaction processorTx =
theirMap.get(payment.getProcessorTransactionId());
if (processorTx != null &&
!payment.getAmountInCents().equals(processorTx.amountInCents())) {
report.addDiscrepancy(new Discrepancy(
DiscrepancyType.AMOUNT_MISMATCH,
payment.getId().toString(),
payment.getAmountInCents(),
processorTx.amountInCents()
));
}
}
report.setTotalOurTransactions(ourPayments.size());
report.setTotalProcessorTransactions(processorRecords.size());
report.setReconciledAt(Instant.now());
reportRepository.save(report);
if (report.hasDiscrepancies()) {
log.warn("Reconciliation found {} discrepancies for {}",
report.getDiscrepancies().size(), date);
alertService.sendReconciliationAlert(report);
}
return report;
}
}
API Design
Payment Creation Endpoint
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
@RestController
@RequestMapping("/api/v1/payments")
@RequiredArgsConstructor
public class PaymentController {
private final PaymentService paymentService;
@PostMapping
public ResponseEntity<PaymentResponse> createPayment(
@RequestBody @Valid CreatePaymentRequest request,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@RequestHeader("X-Merchant-Id") String merchantId) {
PaymentResponse response = paymentService.processPayment(
request.toPaymentRequest(idempotencyKey, merchantId));
return ResponseEntity
.status(HttpStatus.CREATED)
.body(response);
}
@PostMapping("/{paymentId}/capture")
public ResponseEntity<PaymentResponse> capturePayment(
@PathVariable UUID paymentId,
@RequestBody @Valid CaptureRequest request) {
PaymentResponse response = paymentService.capture(paymentId, request);
return ResponseEntity.ok(response);
}
@PostMapping("/{paymentId}/refund")
public ResponseEntity<RefundResponse> refundPayment(
@PathVariable UUID paymentId,
@RequestBody @Valid RefundRequest request,
@RequestHeader("Idempotency-Key") String idempotencyKey) {
RefundResponse response = paymentService.refund(
paymentId, request, idempotencyKey);
return ResponseEntity.ok(response);
}
@GetMapping("/{paymentId}")
public ResponseEntity<PaymentResponse> getPayment(@PathVariable UUID paymentId) {
return ResponseEntity.ok(paymentService.getPayment(paymentId));
}
}
Request/Response DTOs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public record CreatePaymentRequest(
@NotNull @Positive Long amountInCents,
@NotBlank @Size(min = 3, max = 3) String currency,
@NotNull PaymentMethod paymentMethod,
@NotBlank String customerEmail,
Map<String, String> metadata
) {
public PaymentRequest toPaymentRequest(String idempotencyKey, String merchantId) {
return new PaymentRequest(
idempotencyKey, merchantId, amountInCents,
currency, paymentMethod, customerEmail, metadata
);
}
}
public record PaymentResponse(
UUID id,
String status,
Long amountInCents,
String currency,
String paymentMethod,
String processorTransactionId,
Instant createdAt,
String failureReason
) {}
public record RefundRequest(
@NotNull @Positive Long amountInCents,
@NotBlank String reason
) {}
Database Schema
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
CREATE TABLE payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
idempotency_key VARCHAR(255) NOT NULL UNIQUE,
merchant_id VARCHAR(100) NOT NULL,
amount_in_cents BIGINT NOT NULL,
currency VARCHAR(3) NOT NULL,
status VARCHAR(30) NOT NULL,
payment_method VARCHAR(30) NOT NULL,
processor_transaction_id VARCHAR(255),
failure_reason TEXT,
failure_code VARCHAR(50),
refunded_amount_in_cents BIGINT DEFAULT 0,
customer_email VARCHAR(255),
metadata JSONB,
version BIGINT DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_payments_merchant ON payments(merchant_id);
CREATE INDEX idx_payments_status ON payments(status);
CREATE INDEX idx_payments_processor_tx ON payments(processor_transaction_id);
CREATE INDEX idx_payments_created_at ON payments(created_at);
CREATE TABLE payment_status_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payment_id UUID NOT NULL REFERENCES payments(id),
previous_status VARCHAR(30) NOT NULL,
new_status VARCHAR(30) NOT NULL,
reason TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_status_history_payment ON payment_status_history(payment_id);
CREATE TABLE idempotency_keys (
idempotency_key VARCHAR(255) PRIMARY KEY,
request_hash VARCHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL,
response_body TEXT,
response_status_code INTEGER,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
);
CREATE INDEX idx_idempotency_expires ON idempotency_keys(expires_at);
CREATE TABLE webhook_events (
event_id VARCHAR(255) PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(20) NOT NULL,
error_message TEXT,
received_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
processed_at TIMESTAMP WITH TIME ZONE
);
Scaling Considerations
Read/Write Separation
Payment reads (status checks, history) vastly outnumber writes (new payments). Use read replicas:
1
2
3
4
5
6
spring:
datasource:
primary:
url: jdbc:postgresql://primary-db:5432/payments
replica:
url: jdbc:postgresql://replica-db:5432/payments
1
2
3
4
5
6
7
8
9
10
11
@Service
public class PaymentQueryService {
@Transactional(readOnly = true)
@TargetDataSource("replica")
public PaymentResponse getPayment(UUID paymentId) {
return paymentRepository.findById(paymentId)
.map(this::toResponse)
.orElseThrow(() -> new PaymentNotFoundException(paymentId));
}
}
Partitioning Strategy
For high-volume systems, partition the payments table by creation date:
1
2
3
4
5
6
7
8
CREATE TABLE payments (
-- same columns as above
) PARTITION BY RANGE (created_at);
CREATE TABLE payments_2025_q1 PARTITION OF payments
FOR VALUES FROM ('2025-01-01') TO ('2025-04-01');
CREATE TABLE payments_2025_q2 PARTITION OF payments
FOR VALUES FROM ('2025-04-01') TO ('2025-07-01');
Caching Hot Data
Recent payments are queried frequently. Cache with Redis:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Service
@RequiredArgsConstructor
public class CachedPaymentService {
private final PaymentRepository paymentRepository;
private final RedisTemplate<String, PaymentResponse> redisTemplate;
private static final Duration CACHE_TTL = Duration.ofMinutes(5);
public PaymentResponse getPayment(UUID paymentId) {
String cacheKey = "payment:" + paymentId;
PaymentResponse cached = redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
return cached;
}
Payment payment = paymentRepository.findById(paymentId)
.orElseThrow(() -> new PaymentNotFoundException(paymentId));
PaymentResponse response = toResponse(payment);
// Only cache terminal states (won't change)
if (payment.getStatus().isTerminal()) {
redisTemplate.opsForValue().set(cacheKey, response, CACHE_TTL);
}
return response;
}
}
Interview Tips
When discussing this system in an interview:
Start with requirements — Always clarify functional and non-functional requirements first
Emphasize idempotency — This is the #1 concern interviewers look for. Explain why it matters (network retries, client timeouts, duplicate submissions)
Discuss failure modes — What happens when the processor is down? What about partial failures in the saga? How do you handle webhook delivery failures?
Mention reconciliation — Most candidates forget this. Real payment systems reconcile daily. Mention it to stand out.
Trade-off discussion — Sync vs async processing, consistency vs availability, single DB vs event sourcing. Show that you understand the tradeoffs.
Security surface — PCI-DSS, tokenization (never store raw card numbers), webhook signature verification, rate limiting
Key Takeaways
- The payment status state machine prevents invalid transitions and provides audit trail
- Idempotency must be implemented at multiple layers (API filter + database constraint)
- Retry logic needs careful categorization of retryable vs non-retryable errors
- Webhook handlers must be idempotent — processors guarantee at-least-once delivery
- Saga pattern with compensation handles distributed transactions without 2PC
- Reconciliation catches drift between your system and the payment processor
- Optimistic locking prevents concurrent modifications to payment state
If you found this system design deep dive valuable, give it up to 50 claps and follow @hianupamsinha for more practical Spring Boot, Java, and System Design content.