Payment Processing Best Practices
Payment processing is a critical component of financial systems requiring robust error handling, security compliance, and careful orchestration of multiple states and external dependencies. This guide covers the complete payment lifecycle, security requirements, and integration patterns essential for building reliable payment systems.
Payment Lifecycle
Understanding the payment lifecycle is fundamental to implementing payment systems correctly. Each stage has distinct characteristics, error scenarios, and compliance requirements.
Lifecycle Stages
Authorization
Authorization places a hold on the customer's funds to verify availability without transferring money. This stage validates the payment method, checks for sufficient funds, and provides an authorization code that can be captured later.
Key characteristics:
- Funds are held but not transferred
- Authorization typically expires after 7-30 days depending on the payment processor and card network
- Can be partially or fully captured
- Should be voided if the order is cancelled before capture
Implementation considerations:
public class PaymentAuthorizationService {
private final PaymentGateway gateway;
private final PaymentRepository repository;
private final IdempotencyService idempotency;
/**
* Authorizes a payment with idempotency protection.
*
* The authorization holds funds without capturing them, allowing
* the merchant to verify order fulfillment before final capture.
* Uses idempotency keys to prevent duplicate authorization attempts.
*/
public AuthorizationResult authorize(AuthorizationRequest request, String idempotencyKey) {
// Check for duplicate request using idempotency key
return idempotency.executeIdempotent(idempotencyKey, () -> {
// Validate payment method
validatePaymentMethod(request.getPaymentMethod());
// Call payment gateway
GatewayResponse response = gateway.authorize(
request.getAmount(),
request.getCurrency(),
request.getPaymentMethod(),
request.getMetadata()
);
// Store authorization record
Payment payment = Payment.builder()
.authorizationId(response.getAuthorizationId())
.amount(request.getAmount())
.currency(request.getCurrency())
.status(PaymentStatus.AUTHORIZED)
.expiresAt(calculateExpirationTime(response))
.build();
repository.save(payment);
return AuthorizationResult.success(payment);
});
}
private Instant calculateExpirationTime(GatewayResponse response) {
// Authorization expiration varies by processor
// Stripe: 7 days, Braintree: 30 days
return Instant.now().plus(
response.getAuthorizationExpiryDays(),
ChronoUnit.DAYS
);
}
}
Authorization should always be paired with fraud detection checks (see Fraud Detection Integration) and 3D Secure for applicable transactions (see 3D Secure and Strong Customer Authentication).
Capture
Capture converts an authorization into an actual transfer of funds. This occurs when the merchant is ready to fulfill the order or service. Captures can be full or partial, allowing for scenarios like partial shipments.
Key characteristics:
- Must reference a valid authorization
- Can capture less than the authorized amount but not more
- Multiple partial captures may be supported depending on the processor
- Once captured, funds move toward settlement
Implementation patterns:
public class PaymentCaptureService {
private final PaymentGateway gateway;
private final PaymentRepository repository;
private final EventPublisher eventPublisher;
/**
* Captures an authorized payment, initiating fund transfer.
*
* Supports partial captures for scenarios like partial order fulfillment.
* Publishes events for downstream systems (inventory, accounting).
*/
public CaptureResult capture(String authorizationId, BigDecimal amount) {
Payment payment = repository.findByAuthorizationId(authorizationId)
.orElseThrow(() -> new PaymentNotFoundException(authorizationId));
// Validate state transition
if (payment.getStatus() != PaymentStatus.AUTHORIZED) {
throw new InvalidPaymentStateException(
"Cannot capture payment in state: " + payment.getStatus()
);
}
// Check authorization expiration
if (payment.isExpired()) {
payment.setStatus(PaymentStatus.AUTHORIZATION_EXPIRED);
repository.save(payment);
throw new AuthorizationExpiredException(authorizationId);
}
// Validate capture amount
BigDecimal remainingAmount = payment.getAuthorizedAmount()
.subtract(payment.getCapturedAmount());
if (amount.compareTo(remainingAmount) > 0) {
throw new InvalidCaptureAmountException(
"Capture amount exceeds remaining authorized amount"
);
}
// Execute capture
GatewayResponse response = gateway.capture(authorizationId, amount);
// Update payment state
payment.addCapture(amount, response.getCaptureId());
if (payment.isFullyCaptured()) {
payment.setStatus(PaymentStatus.CAPTURED);
} else {
payment.setStatus(PaymentStatus.PARTIALLY_CAPTURED);
}
repository.save(payment);
// Publish domain event for downstream processing
eventPublisher.publish(new PaymentCapturedEvent(
payment.getId(),
amount,
payment.getCurrency()
));
return CaptureResult.success(payment);
}
}
Partial captures are common in e-commerce when items ship separately. Track captured amounts carefully to prevent over-capture.
Settlement
Settlement is the process where captured funds are transferred from the payment processor to the merchant's bank account. This is typically a batch process that occurs daily.
Key characteristics:
- Asynchronous batch process (usually daily)
- Controlled by payment processor, not merchant
- May have fees deducted
- Settlement reports must be reconciled (see Payment Reconciliation)
Merchants don't directly control settlement timing but must account for it in financial reporting and reconciliation processes.
Refund
Refunds return captured funds to the customer. They can be full or partial and must reference a captured payment.
Key characteristics:
- Can only refund captured payments
- Typically takes 5-10 business days to appear on customer's account
- May be subject to processor fees
- Should update inventory and accounting systems
Implementation with audit trail:
public class PaymentRefundService {
private final PaymentGateway gateway;
private final PaymentRepository repository;
private final AuditLogger auditLogger;
private final EventPublisher eventPublisher;
/**
* Processes a refund for a captured payment.
*
* Refunds are irreversible operations that must be carefully
* audited and validated. All refunds require a reason code
* for compliance and reconciliation purposes.
*/
@Transactional
public RefundResult refund(RefundRequest request) {
Payment payment = repository.findById(request.getPaymentId())
.orElseThrow(() -> new PaymentNotFoundException(request.getPaymentId()));
// Validate refundable state
if (!payment.isRefundable()) {
throw new InvalidPaymentStateException(
"Payment cannot be refunded in state: " + payment.getStatus()
);
}
// Calculate refundable amount
BigDecimal refundableAmount = payment.getCapturedAmount()
.subtract(payment.getRefundedAmount());
if (request.getAmount().compareTo(refundableAmount) > 0) {
throw new InvalidRefundAmountException(
"Refund amount exceeds refundable amount"
);
}
// Execute refund through gateway
GatewayResponse response = gateway.refund(
payment.getCaptureId(),
request.getAmount(),
request.getReason()
);
// Update payment state
payment.addRefund(
request.getAmount(),
response.getRefundId(),
request.getReason()
);
if (payment.isFullyRefunded()) {
payment.setStatus(PaymentStatus.REFUNDED);
} else {
payment.setStatus(PaymentStatus.PARTIALLY_REFUNDED);
}
repository.save(payment);
// Audit log with reason
auditLogger.log(AuditEvent.builder()
.eventType(AuditEventType.PAYMENT_REFUNDED)
.entityId(payment.getId())
.amount(request.getAmount())
.reason(request.getReason())
.initiatedBy(request.getInitiatedBy())
.timestamp(Instant.now())
.build());
// Publish event for inventory and accounting systems
eventPublisher.publish(new PaymentRefundedEvent(
payment.getId(),
request.getAmount(),
request.getReason()
));
return RefundResult.success(payment);
}
}
Refunds require careful audit logging including who initiated the refund, why, and when. This information is critical for both compliance and dispute resolution (see Audit Logging Requirements).
Chargeback
Chargebacks occur when a customer disputes a transaction with their card issuer. This is a formal dispute process that requires evidence submission and can result in fund reversal.
Key characteristics:
- Initiated by the cardholder through their bank
- Merchant has limited time to respond (typically 7-21 days)
- Requires evidence submission (receipts, delivery confirmation, communication logs)
- High chargeback rates can lead to increased processing fees or account termination
- Different from refunds (customer-initiated vs bank-initiated)
Chargeback handling requires integration with payment processor APIs to receive notifications and submit evidence. Monitor chargeback ratios as part of fraud detection to identify patterns.
Idempotency for Payment Operations
Idempotency ensures that duplicate payment requests (caused by network retries, browser back buttons, or user double-clicks) don't result in duplicate charges. This is non-negotiable for payment operations.
Why Idempotency Matters
Network failures, timeouts, and user behavior can cause the same payment request to be sent multiple times. Without idempotency:
- Customers may be charged twice
- Financial reconciliation becomes complex
- Compliance and audit requirements are violated
Idempotency Key Design
/**
* Idempotency service that prevents duplicate payment operations.
*
* Uses idempotency keys (client-provided unique identifiers) to detect
* and prevent duplicate requests. Stores the result of the first execution
* and returns it for subsequent requests with the same key.
*
* Idempotency keys have a limited lifespan (typically 24 hours) after which
* they can be recycled.
*/
@Service
public class IdempotencyService {
private final IdempotencyStore store;
private final Duration idempotencyWindow = Duration.ofHours(24);
/**
* Executes an operation idempotently.
*
* If the idempotency key has been seen before, returns the cached result.
* Otherwise, executes the operation, caches the result, and returns it.
*
* @param key Unique identifier for this operation (e.g., UUID)
* @param operation The operation to execute
* @return Result from either the first execution or cached result
*/
public <T> T executeIdempotent(String key, Supplier<T> operation) {
// Check for existing result
Optional<IdempotencyRecord<T>> existing = store.find(key);
if (existing.isPresent()) {
IdempotencyRecord<T> record = existing.get();
// Return cached result if within window
if (record.isWithinWindow(idempotencyWindow)) {
return record.getResult();
}
// Expired, allow re-execution
store.delete(key);
}
// Execute operation
try {
T result = operation.get();
// Cache successful result
store.save(IdempotencyRecord.<T>builder()
.key(key)
.result(result)
.timestamp(Instant.now())
.build());
return result;
} catch (Exception e) {
// Don't cache failures - allow retry with same key
throw e;
}
}
}
Idempotency Key Generation
Idempotency keys should be:
- Unique: Generated client-side using UUIDs or similar
- Deterministic for retries: Same operation uses same key
- Request-scoped: Different operations use different keys
// Client-side idempotency key generation
class PaymentService {
/**
* Submits a payment with idempotency protection.
*
* Generates a unique idempotency key for the payment request
* and includes it in the API call. If the request fails due to
* network issues, retrying with the same key prevents duplicate charges.
*/
async submitPayment(paymentRequest: PaymentRequest): Promise<PaymentResult> {
// Generate idempotency key once per payment attempt
const idempotencyKey = this.generateIdempotencyKey(paymentRequest);
// Store key to ensure retries use the same key
sessionStorage.setItem('payment-idempotency-key', idempotencyKey);
try {
const response = await fetch('/api/payments', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify(paymentRequest),
});
if (!response.ok) {
throw new Error('Payment failed');
}
// Clear key on success
sessionStorage.removeItem('payment-idempotency-key');
return await response.json();
} catch (error) {
// Key remains in session storage for retry
throw error;
}
}
private generateIdempotencyKey(request: PaymentRequest): string {
// Check for existing key (retry scenario)
const existingKey = sessionStorage.getItem('payment-idempotency-key');
if (existingKey) {
return existingKey;
}
// Generate new key combining timestamp and random UUID
// This ensures uniqueness even if user makes multiple payments
return `${request.userId}-${Date.now()}-${crypto.randomUUID()}`;
}
}
The idempotency key must be included in HTTP headers (convention is Idempotency-Key header) and validated server-side before processing.
Storage Considerations
Idempotency records must be stored durably:
- Database table: For long-term auditability
- Redis/Memcached: For high-performance lookups with TTL
- Hybrid approach: Redis for active keys, database for historical audit
@Entity
@Table(name = "idempotency_records", indexes = {
@Index(name = "idx_idempotency_key", columnList = "idempotency_key"),
@Index(name = "idx_created_at", columnList = "created_at")
})
public class IdempotencyRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "idempotency_key", nullable = false, unique = true)
private String idempotencyKey;
@Column(name = "result_payload", columnDefinition = "TEXT")
private String resultPayload; // JSON serialized result
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
// Periodic cleanup job removes expired records
}
Implement a scheduled job to clean up expired idempotency records to prevent unbounded growth.
Retry Strategies for Payment Failures
Payment operations involve multiple external systems (payment gateways, card networks, banks) that can fail temporarily. Implementing appropriate retry strategies is essential for reliability.
Failure Categories
Different failures require different retry strategies:
| Failure Type | Retry? | Strategy | Example |
|---|---|---|---|
| Network timeout | Yes | Exponential backoff | Connection timeout |
| Server error (5xx) | Yes | Exponential backoff with jitter | Gateway overload |
| Rate limit (429) | Yes | Respect Retry-After header | API quota exceeded |
| Invalid request (4xx) | No | Return error to client | Invalid card number |
| Insufficient funds | No | Return error to client | Declined transaction |
| Fraud detected | No | Return error, log | Suspicious activity |
Exponential Backoff Implementation
/**
* Retry handler for payment gateway operations.
*
* Implements exponential backoff with jitter to avoid thundering herd
* when multiple clients retry simultaneously. Only retries transient
* failures (timeouts, 5xx errors, rate limits).
*/
public class PaymentRetryHandler {
private static final int MAX_RETRIES = 3;
private static final Duration INITIAL_DELAY = Duration.ofMillis(100);
private static final Duration MAX_DELAY = Duration.ofSeconds(10);
private final Random jitterRandom = new Random();
/**
* Executes a payment operation with automatic retry logic.
*
* Uses exponential backoff: 100ms, 200ms, 400ms (plus jitter)
* Jitter prevents synchronized retries across multiple clients.
*
* @param operation The payment operation to execute
* @return Result of the operation
* @throws PaymentGatewayException if all retries exhausted
*/
public <T> T executeWithRetry(Supplier<T> operation) {
int attempt = 0;
Exception lastException = null;
while (attempt < MAX_RETRIES) {
try {
return operation.get();
} catch (PaymentGatewayException e) {
lastException = e;
// Check if error is retryable
if (!isRetryable(e)) {
throw e;
}
attempt++;
if (attempt >= MAX_RETRIES) {
break;
}
// Calculate backoff with jitter
Duration delay = calculateBackoff(attempt);
try {
Thread.sleep(delay.toMillis());
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new PaymentGatewayException("Retry interrupted", ie);
}
}
}
throw new PaymentGatewayException(
"Payment operation failed after " + MAX_RETRIES + " attempts",
lastException
);
}
private boolean isRetryable(PaymentGatewayException e) {
return e.isTimeout() ||
e.isServerError() ||
e.isRateLimited();
}
private Duration calculateBackoff(int attempt) {
// Exponential backoff: 100ms * 2^attempt
long exponentialMillis = INITIAL_DELAY.toMillis() * (1L << attempt);
long cappedMillis = Math.min(exponentialMillis, MAX_DELAY.toMillis());
// Add jitter: +/- 25% of delay
long jitterMillis = cappedMillis / 4;
long jitter = jitterRandom.nextLong(jitterMillis * 2) - jitterMillis;
return Duration.ofMillis(cappedMillis + jitter);
}
}
Exponential backoff with jitter prevents the "thundering herd" problem where many clients retry simultaneously, overwhelming the recovering service.
Circuit Breaker Pattern
For sustained failures, implement circuit breakers to fail fast and prevent resource exhaustion:
/**
* Circuit breaker for payment gateway integration.
*
* Opens circuit (stops calling gateway) after threshold of consecutive
* failures to prevent cascading failures and resource exhaustion.
* Periodically attempts recovery by allowing test requests through.
*
* States:
* - CLOSED: Normal operation, requests flow through
* - OPEN: Failures exceeded threshold, requests fail fast
* - HALF_OPEN: Testing recovery, limited requests allowed
*/
@Service
public class PaymentGatewayCircuitBreaker {
private static final int FAILURE_THRESHOLD = 5;
private static final Duration OPEN_TIMEOUT = Duration.ofSeconds(30);
private enum State { CLOSED, OPEN, HALF_OPEN }
private State state = State.CLOSED;
private int consecutiveFailures = 0;
private Instant openedAt;
public <T> T execute(Supplier<T> operation) {
if (state == State.OPEN) {
// Check if timeout elapsed to attempt recovery
if (Instant.now().isAfter(openedAt.plus(OPEN_TIMEOUT))) {
state = State.HALF_OPEN;
} else {
throw new CircuitBreakerOpenException(
"Payment gateway circuit breaker is open"
);
}
}
try {
T result = operation.get();
onSuccess();
return result;
} catch (Exception e) {
onFailure();
throw e;
}
}
private void onSuccess() {
consecutiveFailures = 0;
if (state == State.HALF_OPEN) {
state = State.CLOSED; // Recovery successful
}
}
private void onFailure() {
consecutiveFailures++;
if (consecutiveFailures >= FAILURE_THRESHOLD) {
state = State.OPEN;
openedAt = Instant.now();
}
}
}
Circuit breakers should be monitored via metrics (see Observability) to alert teams when payment services are degraded.
Idempotency and Retries
Retries must be combined with idempotency to ensure safe retry behavior:
public PaymentResult processPayment(PaymentRequest request, String idempotencyKey) {
// Idempotency check happens first
return idempotencyService.executeIdempotent(idempotencyKey, () -> {
// Retry logic happens inside idempotent operation
return retryHandler.executeWithRetry(() -> {
// Circuit breaker wraps the actual gateway call
return circuitBreaker.execute(() -> {
return paymentGateway.charge(request);
});
});
});
}
This layering ensures that even with retries, duplicate charges are prevented.
Payment Reconciliation
Reconciliation is the process of matching internal payment records with payment processor settlement reports to ensure consistency and detect discrepancies.
Why Reconciliation Is Critical
- Detect missing transactions: Payments recorded internally but not settled
- Identify duplicate transactions: Payments settled multiple times
- Calculate fees accurately: Verify processor fees match expectations
- Compliance requirements: Financial audit trails require reconciliation
- Fraud detection: Unusual settlement patterns may indicate fraud
Reconciliation Process
Implementation
/**
* Service for reconciling payment transactions with processor settlement reports.
*
* Runs daily (or more frequently) to match internal payment records with
* the payment processor's settlement data. Identifies and logs discrepancies
* for investigation.
*/
@Service
public class PaymentReconciliationService {
private final PaymentGateway gateway;
private final PaymentRepository paymentRepository;
private final ReconciliationRepository reconciliationRepository;
private final AlertService alertService;
/**
* Performs reconciliation for a specific date.
*
* Compares internal payment records with the processor's settlement
* report. Logs all discrepancies including:
* - Missing transactions (in one system but not the other)
* - Amount mismatches
* - Status inconsistencies
*/
@Scheduled(cron = "0 0 3 * * *") // Run at 3 AM daily
public void reconcileDaily() {
LocalDate settlementDate = LocalDate.now().minusDays(1);
reconcile(settlementDate);
}
public ReconciliationResult reconcile(LocalDate settlementDate) {
// Fetch settlement report from processor
SettlementReport gatewayReport = gateway.getSettlementReport(settlementDate);
// Fetch internal transactions for the same period
List<Payment> internalPayments = paymentRepository
.findBySettlementDate(settlementDate);
// Create maps for matching
Map<String, SettlementTransaction> gatewayMap = gatewayReport
.getTransactions()
.stream()
.collect(Collectors.toMap(
SettlementTransaction::getTransactionId,
Function.identity()
));
Map<String, Payment> internalMap = internalPayments
.stream()
.collect(Collectors.toMap(
Payment::getGatewayTransactionId,
Function.identity()
));
List<Discrepancy> discrepancies = new ArrayList<>();
// Check for missing or mismatched transactions
for (Payment internal : internalPayments) {
String txnId = internal.getGatewayTransactionId();
SettlementTransaction gateway = gatewayMap.get(txnId);
if (gateway == null) {
// Transaction in internal system but not in settlement report
discrepancies.add(Discrepancy.builder()
.type(DiscrepancyType.MISSING_IN_GATEWAY)
.internalPaymentId(internal.getId())
.amount(internal.getAmount())
.description("Transaction not found in gateway settlement report")
.build());
} else {
// Transaction found, check for amount mismatch
if (!internal.getAmount().equals(gateway.getAmount())) {
discrepancies.add(Discrepancy.builder()
.type(DiscrepancyType.AMOUNT_MISMATCH)
.internalPaymentId(internal.getId())
.gatewayTransactionId(txnId)
.internalAmount(internal.getAmount())
.gatewayAmount(gateway.getAmount())
.description("Amount mismatch between systems")
.build());
}
// Mark as matched
gatewayMap.remove(txnId);
}
}
// Check for transactions in gateway but not in internal system
for (SettlementTransaction gateway : gatewayMap.values()) {
discrepancies.add(Discrepancy.builder()
.type(DiscrepancyType.MISSING_IN_INTERNAL)
.gatewayTransactionId(gateway.getTransactionId())
.amount(gateway.getAmount())
.description("Transaction in gateway but not in internal system")
.build());
}
// Store reconciliation result
ReconciliationRecord record = ReconciliationRecord.builder()
.settlementDate(settlementDate)
.reconciledAt(Instant.now())
.totalInternalTransactions(internalPayments.size())
.totalGatewayTransactions(gatewayReport.getTransactions().size())
.matchedTransactions(internalPayments.size() - discrepancies.size())
.discrepancies(discrepancies)
.build();
reconciliationRepository.save(record);
// Alert if discrepancies found
if (!discrepancies.isEmpty()) {
alertService.sendAlert(
AlertLevel.HIGH,
"Payment reconciliation discrepancies",
String.format("Found %d discrepancies for %s",
discrepancies.size(),
settlementDate)
);
}
return ReconciliationResult.of(record);
}
}
Handling Discrepancies
When discrepancies are identified:
- Investigate immediately: Missing or mismatched transactions require prompt investigation
- Document resolution: Record why the discrepancy occurred and how it was resolved
- Adjust records: May require manual adjustment entries
- Update processes: If systemic issues found, update integration logic
Reconciliation discrepancies often indicate:
- Timing differences (transaction settled next day)
- Failed webhooks (see Webhook Handling)
- Partial captures not recorded correctly
- Refunds processed outside the application
PCI-DSS Compliance Requirements
PCI-DSS (Payment Card Industry Data Security Standard) defines security requirements for handling credit card data. Non-compliance can result in fines, increased processing fees, or loss of ability to process cards.
Scope Reduction
The most effective PCI-DSS strategy is to minimize scope by never touching raw card data:
By tokenizing card data in the browser and only handling tokens server-side, your backend infrastructure is out of PCI-DSS scope.
Tokenization Implementation
/**
* Client-side payment tokenization using Stripe Elements.
*
* Card data never touches your servers. Stripe.js collects card
* information securely and returns a token that your backend can
* use to process the payment.
*/
class PaymentTokenizer {
private stripe: Stripe;
private cardElement: StripeCardElement;
constructor(publishableKey: string) {
// Stripe.js loaded from CDN, PCI-compliant
this.stripe = Stripe(publishableKey);
const elements = this.stripe.elements();
// Create card element that handles PCI-compliant input
this.cardElement = elements.create('card', {
style: {
base: {
fontSize: '16px',
color: '#32325d',
},
},
});
}
/**
* Mounts the secure card input element to the page.
* Card data is isolated in an iframe, never accessible to your JS.
*/
mount(elementId: string): void {
this.cardElement.mount(`#${elementId}`);
}
/**
* Tokenizes card data without your server touching raw card numbers.
*
* Returns a single-use token that can be sent to your backend
* for payment processing. The token is not PCI-sensitive.
*/
async tokenize(): Promise<PaymentToken> {
const result = await this.stripe.createToken(this.cardElement);
if (result.error) {
throw new PaymentTokenizationError(result.error.message);
}
// Token can be safely sent to your backend
return {
token: result.token.id,
last4: result.token.card.last4,
brand: result.token.card.brand,
expiryMonth: result.token.card.exp_month,
expiryYear: result.token.card.exp_year,
};
}
}
Your backend receives only the token, never raw card data:
@PostMapping("/payments")
public PaymentResponse processPayment(
@RequestBody PaymentRequest request,
@RequestHeader("Idempotency-Key") String idempotencyKey
) {
// request.getCardToken() contains token, not raw card data
// Your system is out of PCI scope
return paymentService.processPayment(
request.getAmount(),
request.getCurrency(),
request.getCardToken(), // Token from Stripe.js
idempotencyKey
);
}
PCI-DSS Requirements (If Handling Card Data)
If you must handle card data directly (not recommended), you must comply with all PCI-DSS requirements:
| Requirement | Description | Implementation |
|---|---|---|
| Requirement 1 | Firewall configuration | Network segmentation, VPC isolation |
| Requirement 2 | No default passwords | Hardened configurations, secrets management (see Secrets Management) |
| Requirement 3 | Protect stored data | Encrypt at rest (AES-256), encrypt in transit (TLS 1.2+) |
| Requirement 4 | Encrypt transmission | TLS 1.2+, certificate management |
| Requirement 5 | Anti-malware | Endpoint protection, vulnerability scanning |
| Requirement 6 | Secure systems | Patch management, secure SDLC, code review (see Code Review) |
| Requirement 7 | Access control | Least privilege, role-based access |
| Requirement 8 | Authentication | Strong passwords, MFA (see Authentication) |
| Requirement 9 | Physical security | Data center security controls |
| Requirement 10 | Logging and monitoring | Audit logs (see Audit Logging), SIEM integration |
| Requirement 11 | Security testing | Penetration testing, vulnerability scanning (see Security Testing) |
| Requirement 12 | Security policy | Information security policies, training |
SAQ (Self-Assessment Questionnaire) Types
PCI-DSS provides different SAQ levels based on how you handle card data:
- SAQ A: E-commerce using redirect or iframe (Stripe Checkout, PayPal) - ~20 questions
- SAQ A-EP: E-commerce with direct integration but no card data storage - ~160 questions
- SAQ D: All other merchants - ~300+ questions, requires QSA audit
Most organizations should target SAQ A or A-EP by using hosted payment pages or tokenization.
Fraud Detection Integration
Fraud detection systems analyze transaction patterns to identify suspicious activity before payments are authorized.
Fraud Signals
Common fraud indicators:
- Velocity checks: Multiple transactions from same user/card in short timeframe
- Amount anomalies: Transaction amount significantly different from user's history
- Geographic mismatches: Billing address in one country, IP address in another
- Device fingerprinting: Unrecognized device, VPN usage, browser anomalies
- Failed authorization patterns: Multiple failed attempts before successful charge
- Email/phone verification: Disposable email addresses, unverified phone numbers
Integration Pattern
/**
* Fraud detection service that evaluates transactions before authorization.
*
* Performs real-time risk scoring by analyzing transaction attributes,
* user behavior patterns, and device information. Integrates with
* third-party fraud detection services and maintains internal risk models.
*/
@Service
public class FraudDetectionService {
private final FraudProviderClient fraudProvider;
private final UserBehaviorAnalyzer behaviorAnalyzer;
private final RiskRuleEngine ruleEngine;
/**
* Evaluates transaction risk before authorization.
*
* Returns a risk score (0-100) and recommendation (approve/review/decline).
* High-risk transactions should be declined or flagged for manual review.
*/
public FraudAssessment assessTransaction(TransactionContext context) {
// Collect fraud signals
FraudSignals signals = FraudSignals.builder()
.userId(context.getUserId())
.amount(context.getAmount())
.currency(context.getCurrency())
.ipAddress(context.getIpAddress())
.deviceFingerprint(context.getDeviceFingerprint())
.billingAddress(context.getBillingAddress())
.shippingAddress(context.getShippingAddress())
.email(context.getEmail())
.build();
// Check velocity rules
VelocityCheck velocityCheck = checkVelocity(context.getUserId(), signals);
// Analyze user behavior patterns
BehaviorScore behaviorScore = behaviorAnalyzer.analyzeUser(context.getUserId());
// Call external fraud detection service
ExternalFraudScore externalScore = fraudProvider.score(signals);
// Apply internal rule engine
RiskScore internalScore = ruleEngine.evaluate(signals);
// Combine scores into overall risk assessment
int combinedScore = calculateCombinedScore(
velocityCheck.getScore(),
behaviorScore.getScore(),
externalScore.getScore(),
internalScore.getScore()
);
FraudRecommendation recommendation = determineRecommendation(combinedScore);
return FraudAssessment.builder()
.riskScore(combinedScore)
.recommendation(recommendation)
.signals(signals)
.velocityCheck(velocityCheck)
.behaviorScore(behaviorScore)
.externalScore(externalScore)
.internalScore(internalScore)
.build();
}
private VelocityCheck checkVelocity(String userId, FraudSignals signals) {
// Check transaction velocity: how many transactions in last N minutes?
int transactionsLast10Min = countRecentTransactions(userId, Duration.ofMinutes(10));
int transactionsLast1Hour = countRecentTransactions(userId, Duration.ofHours(1));
BigDecimal amountLast10Min = sumRecentTransactions(userId, Duration.ofMinutes(10));
BigDecimal amountLast1Hour = sumRecentTransactions(userId, Duration.ofHours(1));
List<String> violations = new ArrayList<>();
int score = 0;
// Velocity thresholds
if (transactionsLast10Min > 3) {
violations.add("More than 3 transactions in 10 minutes");
score += 30;
}
if (transactionsLast1Hour > 10) {
violations.add("More than 10 transactions in 1 hour");
score += 40;
}
if (amountLast1Hour.compareTo(new BigDecimal("10000")) > 0) {
violations.add("Transaction volume exceeds $10,000 in 1 hour");
score += 50;
}
return VelocityCheck.builder()
.score(score)
.violations(violations)
.transactionsLast10Min(transactionsLast10Min)
.transactionsLast1Hour(transactionsLast1Hour)
.amountLast1Hour(amountLast1Hour)
.build();
}
private FraudRecommendation determineRecommendation(int riskScore) {
if (riskScore >= 80) {
return FraudRecommendation.DECLINE;
} else if (riskScore >= 50) {
return FraudRecommendation.REVIEW;
} else {
return FraudRecommendation.APPROVE;
}
}
}
Integration with Payment Flow
Fraud checks should happen before authorization:
public PaymentResult processPayment(PaymentRequest request, String idempotencyKey) {
// 1. Fraud detection (before authorization)
FraudAssessment fraud = fraudDetectionService.assessTransaction(
buildTransactionContext(request)
);
if (fraud.getRecommendation() == FraudRecommendation.DECLINE) {
throw new FraudDeclinedException("Transaction declined due to fraud risk");
}
if (fraud.getRecommendation() == FraudRecommendation.REVIEW) {
// Queue for manual review, don't authorize yet
return paymentService.queueForReview(request, fraud);
}
// 2. Proceed with authorization if fraud check passes
return paymentService.authorize(request, idempotencyKey);
}
Machine Learning Models
Advanced fraud detection uses machine learning models trained on historical transaction data. These models:
- Learn patterns from past fraudulent transactions
- Adapt to new fraud tactics over time
- Reduce false positives compared to rule-based systems
Integration with ML-based fraud detection services (Stripe Radar, Sift, Riskified) provides constantly updated models without requiring internal ML expertise.
3D Secure and Strong Customer Authentication
3D Secure (3DS) is an authentication protocol that adds an additional verification step for online card transactions. 3D Secure 2 (3DS2) is the current version, required by PSD2 (Payment Services Directive 2) in the European Economic Area for Strong Customer Authentication (SCA).
Why 3D Secure Matters
- Liability shift: With successful 3DS authentication, chargeback liability shifts from merchant to card issuer
- SCA compliance: Required for many European transactions under PSD2
- Fraud reduction: Adds additional authentication factor (something the cardholder knows)
- Customer authentication: Verifies the cardholder, not just the card
3D Secure 2 Flow
Implementation with Stripe
/**
* Service for processing payments with 3D Secure authentication.
*
* Implements 3D Secure 2 (3DS2) for SCA compliance and liability shift.
* Handles both frictionless authentication and challenge flows.
*/
@Service
public class ThreeDSecurePaymentService {
private final StripeClient stripeClient;
private final PaymentRepository paymentRepository;
/**
* Creates a payment with 3DS authentication.
*
* Returns a payment intent that may require additional customer
* action if 3DS challenge is needed. Client must handle the
* potential redirect to the 3DS authentication page.
*/
public PaymentIntentResult createPaymentWithThreeDS(PaymentRequest request) {
// Create payment intent with automatic 3DS handling
PaymentIntentCreateParams params = PaymentIntentCreateParams.builder()
.setAmount(request.getAmount().longValue())
.setCurrency(request.getCurrency())
.setPaymentMethod(request.getPaymentMethodId())
.setConfirmationMethod(PaymentIntentCreateParams.ConfirmationMethod.AUTOMATIC)
// Request 3DS for eligible transactions
.putMetadata("three_d_secure", "required")
.setReturnUrl(request.getReturnUrl()) // For 3DS redirect
.build();
PaymentIntent intent = stripeClient.createPaymentIntent(params);
// Store payment intent
Payment payment = Payment.builder()
.paymentIntentId(intent.getId())
.amount(request.getAmount())
.status(mapStripeStatus(intent.getStatus()))
.threeDSecureRequired(intent.getNextAction() != null)
.build();
paymentRepository.save(payment);
// Check if customer action required
if (intent.getNextAction() != null) {
// 3DS challenge required - return URL for redirect
return PaymentIntentResult.requiresAction(
payment.getId(),
intent.getNextAction().getRedirectToUrl().getUrl()
);
} else {
// Frictionless authentication completed
return PaymentIntentResult.success(payment.getId());
}
}
/**
* Handles return from 3DS authentication.
*
* After customer completes 3DS challenge, they're redirected back
* to the merchant site. This method confirms the payment intent
* and completes the payment.
*/
public PaymentResult handleThreeDSReturn(String paymentIntentId) {
PaymentIntent intent = stripeClient.retrievePaymentIntent(paymentIntentId);
Payment payment = paymentRepository.findByPaymentIntentId(paymentIntentId)
.orElseThrow(() -> new PaymentNotFoundException(paymentIntentId));
// Update payment status based on 3DS result
payment.setStatus(mapStripeStatus(intent.getStatus()));
payment.setThreeDSecureAuthenticated(
intent.getCharges().getData().get(0)
.getPaymentMethodDetails()
.getCard()
.getThreeDSecure() != null
);
paymentRepository.save(payment);
if (intent.getStatus().equals("succeeded")) {
return PaymentResult.success(payment);
} else {
return PaymentResult.failure(
payment,
"3D Secure authentication failed"
);
}
}
}
SCA Exemptions
Not all transactions require 3DS. PSD2 allows exemptions:
| Exemption | Description | Risk |
|---|---|---|
| Low value | Transactions under €30 | Must track cumulative amount |
| Transaction Risk Analysis | Low-risk transactions per acquirer analysis | Requires low fraud rate |
| Merchant-initiated transactions | Recurring payments, subscriptions | Initial setup requires SCA |
| Corporate cards | B2B transactions | Issuer determines |
| Secure corporate payment processes | Authenticated B2B | Complex setup |
Exemptions are requested during payment creation but may be declined by the issuer:
PaymentIntentCreateParams params = PaymentIntentCreateParams.builder()
.setAmount(1500L) // €15.00
.setCurrency("eur")
.setPaymentMethod(paymentMethodId)
// Request low-value exemption
.putMetadata("sca_exemption", "low_value")
.build();
The issuer may still require 3DS even when exemption is requested. Always handle the possibility of 3DS challenge.
Webhook Handling for Payment Status
Payment processors send webhooks (HTTP callbacks) to notify merchants of payment status changes. Webhooks are essential because many payment operations are asynchronous.
Webhook Events
Common webhook events:
payment.authorized: Authorization completedpayment.captured: Payment capturedpayment.failed: Payment failedcharge.refunded: Refund processedcharge.dispute.created: Chargeback initiatedcharge.dispute.closed: Chargeback resolved
Webhook Security
Webhooks must be secured to prevent spoofing:
/**
* Controller for handling payment processor webhooks.
*
* Validates webhook signatures to ensure requests are genuine.
* Processes events idempotently to handle duplicates and retries.
*/
@RestController
@RequestMapping("/webhooks/stripe")
public class StripeWebhookController {
private final WebhookValidator webhookValidator;
private final PaymentEventHandler eventHandler;
private final IdempotencyService idempotencyService;
/**
* Receives and processes Stripe webhook events.
*
* Signature validation prevents webhook spoofing attacks.
* Idempotency handling prevents duplicate processing if Stripe
* retries the webhook.
*/
@PostMapping
public ResponseEntity<String> handleWebhook(
@RequestBody String payload,
@RequestHeader("Stripe-Signature") String signature
) {
// 1. Validate signature to ensure webhook is from Stripe
Event event;
try {
event = webhookValidator.validateAndParse(payload, signature);
} catch (SignatureVerificationException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("Invalid signature");
}
// 2. Process event idempotently using event ID as idempotency key
// Stripe may send the same webhook multiple times
try {
idempotencyService.executeIdempotent(event.getId(), () -> {
eventHandler.handle(event);
return null;
});
} catch (Exception e) {
// Log error but return 200 to prevent retries
// Investigate failed webhooks via monitoring
log.error("Failed to process webhook event: {}", event.getId(), e);
}
// 3. Return 200 immediately to acknowledge receipt
// Processing may continue asynchronously
return ResponseEntity.ok("Webhook received");
}
}
/**
* Validates webhook signatures using HMAC-SHA256.
*
* Stripe includes a signature header computed from the payload
* and your webhook secret. Verification ensures the webhook
* originated from Stripe and hasn't been tampered with.
*/
@Component
public class WebhookValidator {
@Value("${stripe.webhook.secret}")
private String webhookSecret;
public Event validateAndParse(String payload, String signatureHeader)
throws SignatureVerificationException {
// Verify signature using Stripe's library
Event event = Webhook.constructEvent(
payload,
signatureHeader,
webhookSecret
);
return event;
}
}
Webhook Idempotency
Payment processors may send the same webhook multiple times if they don't receive a 200 response quickly. Using the event ID as an idempotency key prevents duplicate processing:
@Service
public class PaymentEventHandler {
private final PaymentRepository paymentRepository;
private final EventPublisher eventPublisher;
/**
* Handles payment status change events from webhooks.
*
* Updates internal payment state and publishes domain events
* for downstream systems (inventory, notifications, analytics).
*/
public void handle(Event event) {
switch (event.getType()) {
case "payment_intent.succeeded":
handlePaymentSucceeded(event);
break;
case "payment_intent.payment_failed":
handlePaymentFailed(event);
break;
case "charge.refunded":
handleRefund(event);
break;
case "charge.dispute.created":
handleDispute(event);
break;
default:
log.info("Unhandled webhook event type: {}", event.getType());
}
}
private void handlePaymentSucceeded(Event event) {
PaymentIntent paymentIntent = (PaymentIntent) event.getData().getObject();
Payment payment = paymentRepository
.findByPaymentIntentId(paymentIntent.getId())
.orElseThrow(() -> new PaymentNotFoundException(paymentIntent.getId()));
// Update status
payment.setStatus(PaymentStatus.SUCCEEDED);
payment.setCompletedAt(Instant.now());
paymentRepository.save(payment);
// Publish domain event
eventPublisher.publish(new PaymentSucceededEvent(
payment.getId(),
payment.getAmount(),
payment.getCurrency()
));
}
}
Webhook Retry Handling
Payment processors typically retry failed webhooks with exponential backoff. Your webhook endpoint must:
- Respond quickly: Return 200 within 5-10 seconds
- Process asynchronously: Queue events for background processing if needed
- Be idempotent: Handle duplicate deliveries gracefully
- Log failures: Monitor webhook failures for investigation
@Service
public class AsynchronousWebhookProcessor {
private final QueueService queueService;
/**
* Queues webhook events for asynchronous processing.
*
* Returns 200 immediately while processing continues in background.
* Prevents webhook timeouts for long-running operations.
*/
public void queueForProcessing(Event event) {
WebhookTask task = WebhookTask.builder()
.eventId(event.getId())
.eventType(event.getType())
.payload(event.getData().toJson())
.receivedAt(Instant.now())
.build();
queueService.enqueue("webhook-processing", task);
}
}
Multi-Currency Considerations
International payments require handling multiple currencies, foreign exchange rates, and cross-border fees.
Currency Precision
Different currencies have different decimal precision:
/**
* Utility for handling currency-specific precision and formatting.
*
* Most currencies use 2 decimal places (e.g., USD, EUR), but some
* use 0 (JPY, KRW) or 3 (BHD, KWD). Using the wrong precision
* results in incorrect amounts.
*/
public class CurrencyUtil {
private static final Map<String, Integer> CURRENCY_DECIMAL_PLACES = Map.of(
"USD", 2, "EUR", 2, "GBP", 2,
"JPY", 0, "KRW", 0, // Zero decimal currencies
"BHD", 3, "KWD", 3 // Three decimal currencies
);
/**
* Converts an amount to the smallest currency unit (cents, pence, etc.).
*
* Payment processors typically work with integer amounts in the
* smallest unit to avoid floating-point precision issues.
*
* Examples:
* - $10.50 USD -> 1050 (cents)
* - ¥1000 JPY -> 1000 (yen, no subunit)
* - 10.500 BHD -> 10500 (fils, 3 decimal places)
*/
public static long toSmallestUnit(BigDecimal amount, String currency) {
int decimalPlaces = CURRENCY_DECIMAL_PLACES.getOrDefault(currency, 2);
return amount.multiply(BigDecimal.TEN.pow(decimalPlaces))
.longValue();
}
/**
* Converts from smallest currency unit to decimal amount.
*/
public static BigDecimal fromSmallestUnit(long amount, String currency) {
int decimalPlaces = CURRENCY_DECIMAL_PLACES.getOrDefault(currency, 2);
return BigDecimal.valueOf(amount)
.divide(BigDecimal.TEN.pow(decimalPlaces));
}
}
Foreign Exchange Rates
For multi-currency pricing, maintain current exchange rates:
@Service
public class ExchangeRateService {
private final ExchangeRateProvider provider;
private final ExchangeRateCache cache;
/**
* Converts amount from one currency to another using current rates.
*
* Caches rates for performance but refreshes periodically.
* For financial accuracy, use rates from the time of transaction.
*/
public BigDecimal convert(
BigDecimal amount,
String fromCurrency,
String toCurrency
) {
if (fromCurrency.equals(toCurrency)) {
return amount;
}
ExchangeRate rate = cache.getRate(fromCurrency, toCurrency)
.orElseGet(() -> {
ExchangeRate fetched = provider.getRate(fromCurrency, toCurrency);
cache.put(fromCurrency, toCurrency, fetched);
return fetched;
});
// Store which rate was used for audit trail
return amount.multiply(rate.getRate())
.setScale(CurrencyUtil.getDecimalPlaces(toCurrency), RoundingMode.HALF_UP);
}
}
Cross-Border Fees
International payments may incur additional fees:
- Currency conversion fees: Charged by payment processor for FX
- Cross-border fees: Additional fee for international transactions
- Network fees: Card network fees for international transactions
Display these fees transparently to customers before payment:
public PaymentQuote calculatePaymentQuote(PaymentRequest request) {
BigDecimal baseAmount = request.getAmount();
String currency = request.getCurrency();
// Calculate processing fee
BigDecimal processingFee = baseAmount.multiply(new BigDecimal("0.029"))
.add(new BigDecimal("0.30")); // 2.9% + $0.30
// Add cross-border fee if applicable
BigDecimal crossBorderFee = BigDecimal.ZERO;
if (isCrossBorder(request)) {
crossBorderFee = baseAmount.multiply(new BigDecimal("0.01")); // 1%
}
BigDecimal totalAmount = baseAmount
.add(processingFee)
.add(crossBorderFee);
return PaymentQuote.builder()
.baseAmount(baseAmount)
.processingFee(processingFee)
.crossBorderFee(crossBorderFee)
.totalAmount(totalAmount)
.currency(currency)
.build();
}
Audit Logging Requirements
Comprehensive audit logging is essential for compliance, fraud investigation, dispute resolution, and troubleshooting.
What to Log
Every payment operation must log:
- Who: User ID, API key, service account
- What: Operation performed (authorize, capture, refund)
- When: Timestamp (use UTC consistently)
- Where: IP address, geographic location, device information
- Why: Business reason (order ID, customer request, system-initiated)
- Result: Success/failure, error codes, amounts
- Context: Full request/response payloads (excluding sensitive card data)
Implementation
/**
* Audit logger for payment operations.
*
* Logs all payment events with full context for compliance and
* troubleshooting. Audit logs are immutable and retained per
* regulatory requirements (typically 7 years).
*/
@Service
public class PaymentAuditLogger {
private final AuditLogRepository auditLogRepository;
/**
* Creates an audit log entry for a payment operation.
*
* Captures comprehensive context including who performed the action,
* what was done, and the outcome. Never logs sensitive card data.
*/
public void logPaymentOperation(AuditContext context) {
AuditLog log = AuditLog.builder()
// Who
.userId(context.getUserId())
.apiKeyId(context.getApiKeyId())
.serviceAccount(context.getServiceAccount())
// What
.operation(context.getOperation()) // AUTHORIZE, CAPTURE, REFUND
.paymentId(context.getPaymentId())
.amount(context.getAmount())
.currency(context.getCurrency())
// When
.timestamp(Instant.now())
// Where
.ipAddress(context.getIpAddress())
.userAgent(context.getUserAgent())
.geolocation(context.getGeolocation())
// Why
.reason(context.getReason())
.orderId(context.getOrderId())
.metadata(context.getMetadata())
// Result
.status(context.getStatus()) // SUCCESS, FAILURE
.errorCode(context.getErrorCode())
.errorMessage(context.getErrorMessage())
// Context (sanitized)
.requestPayload(sanitize(context.getRequestPayload()))
.responsePayload(sanitize(context.getResponsePayload()))
.build();
auditLogRepository.save(log);
}
/**
* Sanitizes payloads to remove sensitive data.
*
* Removes card numbers, CVV, passwords, and other PCI-sensitive data
* before storing in audit logs.
*/
private String sanitize(String payload) {
if (payload == null) {
return null;
}
return payload
.replaceAll("\"card_number\"\\s*:\\s*\"[^\"]+\"", "\"card_number\":\"[REDACTED]\"")
.replaceAll("\"cvv\"\\s*:\\s*\"[^\"]+\"", "\"cvv\":\"[REDACTED]\"")
.replaceAll("\"password\"\\s*:\\s*\"[^\"]+\"", "\"password\":\"[REDACTED]\"");
}
}
Retention and Access
Audit logs must be:
- Immutable: Use append-only storage
- Retained: Per regulatory requirements (7+ years for financial data)
- Searchable: Index for efficient querying during investigations
- Access-controlled: Only authorized personnel can access
- Backed up: Regular backups with disaster recovery (see Disaster Recovery)
Consider using specialized audit log storage:
- Database table with write-only permissions
- Elasticsearch for searchability
- AWS CloudWatch Logs or Azure Log Analytics
- Dedicated SIEM (Security Information and Event Management) system
Audit Log Queries
Common audit log queries for investigations:
// Find all payment operations by a user
List<AuditLog> logs = auditLogRepository.findByUserId(userId);
// Find all failed payment attempts in last hour
List<AuditLog> failedPayments = auditLogRepository.findByStatusAndTimestampAfter(
AuditStatus.FAILURE,
Instant.now().minus(Duration.ofHours(1))
);
// Find all operations on a specific payment
List<AuditLog> paymentHistory = auditLogRepository.findByPaymentId(paymentId);
// Find suspicious activity: multiple failures from same IP
List<AuditLog> suspiciousActivity = auditLogRepository
.findByIpAddressAndStatusAndTimestampAfter(
ipAddress,
AuditStatus.FAILURE,
Instant.now().minus(Duration.ofMinutes(10))
);
Audit logs are critical for PCI-DSS Requirement 10 (see PCI-DSS Compliance) and incident investigation (see Incident Post-Mortems).
Related Documentation
- Transaction Ledgers: Double-entry bookkeeping and financial ledger implementation
- API Design: RESTful API patterns for payment endpoints
- Security Overview: Authentication, authorization, and data protection
- Observability: Logging, metrics, and monitoring for payment systems
- Error Handling: Payment-specific error codes and handling
- Event-Driven Architecture: Publishing payment events for downstream systems
- Database Transactions: ACID properties for payment data consistency
- Secrets Management: Secure storage of API keys and payment credentials
- Rate Limiting: Protecting payment endpoints from abuse