Why Payment Systems Need Idempotency
/ 5 min read
Table of Contents
Payment systems fail in boring but expensive ways.
A user can click Pay twice. A mobile network can retry the same callback. A payment provider can send the same notification multiple times. Your API can time out even though the transaction succeeded.
Without idempotency, the same payment may be processed more than once.
What is idempotency?
Idempotency means:
The same request can be safely repeated multiple times, but the result should only happen once.
For example, if a customer pays KES 1,000, the system should credit the account once, even if the same payment callback is received three times.
The problem
Imagine this payment callback arrives:
{ "transactionId": "MPESA123456", "accountNumber": "ACC001", "amount": 1000, "status": "SUCCESS"}If the provider sends it twice, a naive system may do this:
Callback 1 received → credit account KES 1,000Callback 2 received → credit account KES 1,000 againThat is a serious financial bug.
Correct flow
Payment Provider | vPayment Callback API | vCheck idempotency key / transaction ID | +-----------------------------+ | | v vAlready processed? Not processed? | | v vReturn previous response Save transaction as processing | v Apply business action | v Mark as processed | v Return success responseMermaid flow diagram
flowchart TD A[Payment Provider sends callback] --> B[Payment Callback API] B --> C{Transaction ID already processed?}
C -->|Yes| D[Return previous successful response] C -->|No| E[Save transaction as PROCESSING]
E --> F[Validate payment details] F --> G[Apply business action] G --> H[Credit wallet / update loan / mark invoice paid] H --> I[Mark transaction as PROCESSED] I --> J[Return success response]
F -->|Invalid| K[Mark transaction as FAILED] K --> L[Return failure response]Basic database design
CREATE TABLE payment_callbacks ( id BIGSERIAL PRIMARY KEY, transaction_id VARCHAR(100) NOT NULL UNIQUE, account_number VARCHAR(50) NOT NULL, amount NUMERIC(18, 2) NOT NULL, status VARCHAR(30) NOT NULL, processing_status VARCHAR(30) NOT NULL, raw_payload TEXT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP);The important part is this:
transaction_id VARCHAR(100) NOT NULL UNIQUEThat unique constraint protects the system even if two duplicate requests arrive at almost the same time.
Spring Boot example
Request DTO
public record PaymentCallbackRequest( String transactionId, String accountNumber, BigDecimal amount, String status) {}Entity
import jakarta.persistence.*;import java.math.BigDecimal;import java.time.LocalDateTime;
@Entity@Table( name = "payment_callbacks", uniqueConstraints = { @UniqueConstraint(name = "uk_payment_transaction_id", columnNames = "transaction_id") })public class PaymentCallback {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(name = "transaction_id", nullable = false, unique = true) private String transactionId;
@Column(name = "account_number", nullable = false) private String accountNumber;
@Column(nullable = false) private BigDecimal amount;
@Column(nullable = false) private String status;
@Column(name = "processing_status", nullable = false) private String processingStatus;
@Column(name = "raw_payload", nullable = false, columnDefinition = "TEXT") private String rawPayload;
@Column(name = "created_at", nullable = false) private LocalDateTime createdAt = LocalDateTime.now();
@Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt = LocalDateTime.now();
protected PaymentCallback() { }
public PaymentCallback( String transactionId, String accountNumber, BigDecimal amount, String status, String processingStatus, String rawPayload ) { this.transactionId = transactionId; this.accountNumber = accountNumber; this.amount = amount; this.status = status; this.processingStatus = processingStatus; this.rawPayload = rawPayload; }
public String getTransactionId() { return transactionId; }
public String getProcessingStatus() { return processingStatus; }
public void markProcessed() { this.processingStatus = "PROCESSED"; this.updatedAt = LocalDateTime.now(); }
public void markFailed() { this.processingStatus = "FAILED"; this.updatedAt = LocalDateTime.now(); }}Repository
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface PaymentCallbackRepository extends JpaRepository<PaymentCallback, Long> {
Optional<PaymentCallback> findByTransactionId(String transactionId);
boolean existsByTransactionId(String transactionId);}Service
import com.fasterxml.jackson.databind.ObjectMapper;import org.springframework.dao.DataIntegrityViolationException;import org.springframework.stereotype.Service;import org.springframework.transaction.annotation.Transactional;
@Servicepublic class PaymentCallbackService {
private final PaymentCallbackRepository repository; private final ObjectMapper objectMapper;
public PaymentCallbackService( PaymentCallbackRepository repository, ObjectMapper objectMapper ) { this.repository = repository; this.objectMapper = objectMapper; }
@Transactional public PaymentCallbackResponse process(PaymentCallbackRequest request) { return repository.findByTransactionId(request.transactionId()) .map(existing -> new PaymentCallbackResponse( existing.getTransactionId(), existing.getProcessingStatus(), "Duplicate callback ignored" )) .orElseGet(() -> processNewCallback(request)); }
private PaymentCallbackResponse processNewCallback(PaymentCallbackRequest request) { try { String rawPayload = objectMapper.writeValueAsString(request);
PaymentCallback callback = new PaymentCallback( request.transactionId(), request.accountNumber(), request.amount(), request.status(), "PROCESSING", rawPayload );
repository.save(callback);
if (!"SUCCESS".equalsIgnoreCase(request.status())) { callback.markFailed(); return new PaymentCallbackResponse( request.transactionId(), "FAILED", "Payment was not successful" ); }
// Business action happens here: // - credit wallet // - update loan repayment // - mark invoice as paid // - publish event to Kafka/RabbitMQ
callback.markProcessed();
return new PaymentCallbackResponse( request.transactionId(), "PROCESSED", "Payment processed successfully" );
} catch (DataIntegrityViolationException duplicateRequest) { return new PaymentCallbackResponse( request.transactionId(), "PROCESSED", "Duplicate callback ignored" ); } catch (Exception exception) { throw new RuntimeException("Failed to process payment callback", exception); } }}Response DTO
public record PaymentCallbackResponse( String transactionId, String status, String message) {}Controller
import org.springframework.http.ResponseEntity;import org.springframework.web.bind.annotation.*;
@RestController@RequestMapping("/api/v1/payments")public class PaymentCallbackController {
private final PaymentCallbackService service;
public PaymentCallbackController(PaymentCallbackService service) { this.service = service; }
@PostMapping("/callback") public ResponseEntity<PaymentCallbackResponse> receiveCallback( @RequestBody PaymentCallbackRequest request ) { PaymentCallbackResponse response = service.process(request); return ResponseEntity.ok(response); }}Why the database constraint matters
Checking first in code is not enough.
This looks safe:
if (!repository.existsByTransactionId(transactionId)) { repository.save(callback);}But two requests can arrive at the same time:
Request A checks → transaction does not existRequest B checks → transaction does not existRequest A savesRequest B savesThat is called a race condition.
The database unique constraint is the final protection.
Better production design
For a real payment system, I would improve this with:
1. Unique transaction reference2. Request signature verification3. Raw payload storage4. Processing status tracking5. Retry-safe business logic6. Dead letter queue for failures7. Audit logs8. Reconciliation reports9. Alerting for suspicious duplicatesFinal architecture
Provider Callback | vAPI Gateway / Controller | vSignature Verification | vIdempotency Check | vStore Raw Callback | vBusiness Processing | +------------------+ | | v vSuccess Failure | | v vMark Processed Mark Failed / Send to DLQ | vReturn 200 OKKey lesson
In payment systems, retries are normal.
The goal is not to prevent retries.
The goal is to make retries safe.
That is why idempotency is one of the most important backend patterns in fintech systems.