Loading content...
Your payment system doesn't directly communicate with Visa or Mastercard. Between your application and the global financial network sits a critical intermediary: the payment gateway. This component encrypts sensitive payment data, routes transactions to the appropriate processor, handles protocol translations, and returns authorization decisions—often within 200-500 milliseconds.
But here's the complexity: there isn't one gateway. There are hundreds. Each with different APIs, different capabilities, different pricing, different geographic coverage, and different reliability characteristics. Enterprise payment systems typically integrate with 3-10 payment gateways simultaneously, routing transactions intelligently based on cost, success rates, regional availability, and payment method support.
This page teaches you how to architect gateway integrations that are maintainable, resilient, and optimized for both cost and reliability.
By the end of this page, you will understand payment gateway architecture, the adapter pattern for multi-gateway support, intelligent routing strategies, tokenization flows for PCI compliance, error handling for external dependencies, and circuit breaker patterns for gateway resilience.
A payment gateway serves as the intermediary between merchants and the payment processing network. Understanding its role and components is essential for designing robust integrations.
Key Gateway Responsibilities:
| Gateway | Strengths | Typical Use Case | Pricing Model |
|---|---|---|---|
| Stripe | Developer experience, extensive API, global coverage | Startups, SaaS, marketplaces | 2.9% + $0.30 per transaction |
| Adyen | Enterprise features, acquiring, unified commerce | Large enterprises, omnichannel retail | Interchange++ pricing |
| Braintree | Drop-in UI, PayPal integration, vaulting | E-commerce, mobile apps | 2.59% + $0.49 per transaction |
| Worldpay | Global acquiring, high-volume processing | Large merchants, B2B | Custom enterprise pricing |
| Checkout.com | Fast onboarding, European coverage, APIs | Marketplaces, high-growth companies | Interchange++ pricing |
| Square | POS integration, small business tools | Retail, restaurants, services | 2.6% + $0.10 per transaction |
These terms are often confused. Gateway = the API you integrate with. Processor = the system that routes transactions to card networks. Acquirer = the bank that deposits funds into the merchant's account. Some companies (like Adyen, Stripe) are all three, while others specialize in just one role.
Each payment gateway has a unique API—different endpoints, request formats, response structures, and error codes. Integrating directly with each gateway creates tightly coupled code that's difficult to maintain and extend.
The Adapter Pattern solves this by defining a common interface that your payment service uses, with concrete adapters for each gateway that translate to/from the gateway-specific formats.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
// ============================================// Common Interface (Port)// ============================================ interface PaymentGateway { readonly name: string; readonly supportedMethods: PaymentMethodType[]; readonly supportedCurrencies: string[]; // Core payment operations authorize(request: AuthorizeRequest): Promise<AuthorizeResponse>; capture(request: CaptureRequest): Promise<CaptureResponse>; void(request: VoidRequest): Promise<VoidResponse>; refund(request: RefundRequest): Promise<RefundResponse>; // Tokenization tokenize(request: TokenizeRequest): Promise<TokenizeResponse>; deleteToken(tokenId: string): Promise<void>; // Health & status healthCheck(): Promise<HealthStatus>; getTransaction(transactionId: string): Promise<TransactionDetails>;} interface AuthorizeRequest { idempotencyKey: string; amount: number; // In smallest currency unit currency: string; // ISO 4217 paymentMethod: PaymentMethodToken; merchantReference: string; capture: boolean; // Auth-only or auth+capture metadata?: Record<string, string>; // For fraud detection customerIp?: string; billingAddress?: Address; shippingAddress?: Address; // 3D Secure threeDSecure?: ThreeDSecureParams;} interface AuthorizeResponse { gatewayTransactionId: string; status: GatewayTransactionStatus; authorizationCode?: string; networkTransactionId?: string; // For 3DS challenges redirectUrl?: string; requiresAction?: boolean; // Risk assessment riskScore?: number; riskAssessment?: RiskAssessment; // Error details if failed declineCode?: string; declineReason?: string; // Raw response for debugging rawResponse?: object;} type GatewayTransactionStatus = | 'authorized' | 'captured' | 'declined' | 'pending' | 'requires_action' | 'error';Stripe Adapter Implementation:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
import Stripe from 'stripe'; class StripeGatewayAdapter implements PaymentGateway { readonly name = 'stripe'; readonly supportedMethods: PaymentMethodType[] = [ 'card', 'apple_pay', 'google_pay', 'sepa_debit', 'us_bank_account' ]; readonly supportedCurrencies = ['USD', 'EUR', 'GBP', 'CAD', /* 135+ currencies */]; private client: Stripe; constructor(config: StripeConfig) { this.client = new Stripe(config.secretKey, { apiVersion: '2023-10-16', timeout: 10000, // 10 second timeout maxNetworkRetries: 2, }); } async authorize(request: AuthorizeRequest): Promise<AuthorizeResponse> { try { // Translate our request to Stripe's format const stripeRequest: Stripe.PaymentIntentCreateParams = { amount: request.amount, currency: request.currency.toLowerCase(), payment_method: request.paymentMethod.token, confirm: true, capture_method: request.capture ? 'automatic' : 'manual', metadata: { ...request.metadata, merchant_reference: request.merchantReference, idempotency_key: request.idempotencyKey, }, }; // Add 3DS parameters if required if (request.threeDSecure) { stripeRequest.payment_method_options = { card: { request_three_d_secure: request.threeDSecure.required ? 'any' : 'automatic', }, }; } // Make the API call with idempotency key const intent = await this.client.paymentIntents.create( stripeRequest, { idempotencyKey: request.idempotencyKey } ); // Translate Stripe response to our format return this.mapStripeIntentToResponse(intent); } catch (error) { return this.handleStripeError(error); } } private mapStripeIntentToResponse( intent: Stripe.PaymentIntent ): AuthorizeResponse { // Map Stripe status to our normalized status const statusMap: Record<string, GatewayTransactionStatus> = { 'requires_payment_method': 'declined', 'requires_confirmation': 'pending', 'requires_action': 'requires_action', 'processing': 'pending', 'requires_capture': 'authorized', 'canceled': 'declined', 'succeeded': 'captured', }; return { gatewayTransactionId: intent.id, status: statusMap[intent.status] || 'error', authorizationCode: intent.charges?.data[0]?.payment_method_details ?.card?.authorization_code, networkTransactionId: intent.charges?.data[0] ?.payment_method_details?.card?.network_transaction_id, redirectUrl: intent.next_action?.redirect_to_url?.url, requiresAction: intent.status === 'requires_action', rawResponse: intent, }; } private handleStripeError(error: unknown): AuthorizeResponse { if (error instanceof Stripe.errors.StripeCardError) { return { gatewayTransactionId: '', status: 'declined', declineCode: error.decline_code || error.code, declineReason: error.message, }; } if (error instanceof Stripe.errors.StripeAPIError) { // Log and potentially retry throw new GatewayUnavailableError('stripe', error.message); } throw error; } // ... other methods (capture, void, refund, tokenize)}Adyen Adapter Implementation (showing different API patterns):
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
class AdyenGatewayAdapter implements PaymentGateway { readonly name = 'adyen'; readonly supportedMethods: PaymentMethodType[] = [ 'card', 'ideal', 'bancontact', 'sofort', 'klarna', 'alipay' ]; readonly supportedCurrencies = ['USD', 'EUR', 'GBP', /* 150+ currencies */]; private baseUrl: string; private apiKey: string; private merchantAccount: string; constructor(config: AdyenConfig) { this.baseUrl = config.isLive ? 'https://checkout-live.adyen.com/v70' : 'https://checkout-test.adyen.com/v70'; this.apiKey = config.apiKey; this.merchantAccount = config.merchantAccount; } async authorize(request: AuthorizeRequest): Promise<AuthorizeResponse> { // Adyen uses a different request structure const adyenRequest = { amount: { value: request.amount, currency: request.currency, }, reference: request.merchantReference, paymentMethod: { storedPaymentMethodId: request.paymentMethod.token, }, merchantAccount: this.merchantAccount, shopperInteraction: 'ContAuth', recurringProcessingModel: 'CardOnFile', captureDelayHours: request.capture ? 0 : undefined, }; const response = await fetch(`${this.baseUrl}/payments`, { method: 'POST', headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json', 'Idempotency-Key': request.idempotencyKey, }, body: JSON.stringify(adyenRequest), }); const result = await response.json(); return this.mapAdyenResponse(result); } private mapAdyenResponse(result: AdyenPaymentResponse): AuthorizeResponse { // Adyen uses different result codes const statusMap: Record<string, GatewayTransactionStatus> = { 'Authorised': 'authorized', 'Captured': 'captured', 'Refused': 'declined', 'Pending': 'pending', 'RedirectShopper': 'requires_action', 'ChallengeShopper': 'requires_action', 'Error': 'error', }; return { gatewayTransactionId: result.pspReference || '', status: statusMap[result.resultCode] || 'error', declineCode: result.refusalReasonCode, declineReason: result.refusalReason, redirectUrl: result.action?.url, requiresAction: result.action != null, rawResponse: result, }; }} // Factory for creating appropriate adapterclass GatewayFactory { private adapters: Map<string, PaymentGateway> = new Map(); constructor(configs: GatewayConfigs) { if (configs.stripe) { this.adapters.set('stripe', new StripeGatewayAdapter(configs.stripe)); } if (configs.adyen) { this.adapters.set('adyen', new AdyenGatewayAdapter(configs.adyen)); } if (configs.braintree) { this.adapters.set('braintree', new BraintreeGatewayAdapter(configs.braintree)); } } getGateway(name: string): PaymentGateway { const gateway = this.adapters.get(name); if (!gateway) { throw new Error(`Unknown gateway: ${name}`); } return gateway; } getAvailableGateways(): PaymentGateway[] { return Array.from(this.adapters.values()); }}The adapter pattern provides: (1) Loose coupling — payment service doesn't know gateway specifics, (2) Easy testing — mock the interface, not HTTP calls, (3) Simple addition — add new gateways without modifying existing code, (4) Normalized errors — consistent error handling regardless of gateway.
With multiple gateways integrated, the next challenge is deciding which gateway to use for each transaction. Intelligent routing can significantly impact:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
interface RoutingDecision { primaryGateway: string; fallbackGateways: string[]; reason: string;} interface RoutingContext { amount: number; currency: string; paymentMethod: PaymentMethodType; cardNetwork?: CardNetwork; // visa, mastercard, amex cardCountry?: string; // Issuing country merchantCountry: string; merchantCategory: string; // MCC code isRecurring: boolean; riskScore?: number;} class GatewayRouter { private gateways: Map<string, PaymentGateway>; private routingRules: RoutingRule[]; private gatewayHealth: Map<string, CircuitBreaker>; private successRates: Map<string, GatewaySuccessRate>; constructor( gateways: Map<string, PaymentGateway>, config: RoutingConfig ) { this.gateways = gateways; this.routingRules = config.rules; this.gatewayHealth = this.initializeCircuitBreakers(gateways); this.successRates = new Map(); } selectGateway(context: RoutingContext): RoutingDecision { // Step 1: Filter to capable gateways const capable = this.filterCapableGateways(context); if (capable.length === 0) { throw new NoCapableGatewayError(context); } // Step 2: Filter to healthy gateways const healthy = capable.filter(g => this.gatewayHealth.get(g)?.isHealthy() ?? true ); if (healthy.length === 0) { // All gateways unhealthy - try primary anyway return { primaryGateway: capable[0], fallbackGateways: capable.slice(1), reason: 'all_gateways_degraded', }; } // Step 3: Apply routing rules const ruleResult = this.applyRoutingRules(healthy, context); if (ruleResult) { return ruleResult; } // Step 4: Cost-optimized or success-rate-optimized selection const ranked = this.rankGateways(healthy, context); return { primaryGateway: ranked[0].gateway, fallbackGateways: ranked.slice(1).map(r => r.gateway), reason: ranked[0].reason, }; } private filterCapableGateways(context: RoutingContext): string[] { const capable: string[] = []; for (const [name, gateway] of this.gateways) { // Check payment method support if (!gateway.supportedMethods.includes(context.paymentMethod)) { continue; } // Check currency support if (!gateway.supportedCurrencies.includes(context.currency)) { continue; } capable.push(name); } return capable; } private applyRoutingRules( gateways: string[], context: RoutingContext ): RoutingDecision | null { for (const rule of this.routingRules) { if (this.ruleMatches(rule, context)) { const targetGateway = rule.targetGateway; if (gateways.includes(targetGateway)) { return { primaryGateway: targetGateway, fallbackGateways: gateways.filter(g => g !== targetGateway), reason: `rule:${rule.name}`, }; } } } return null; } private rankGateways( gateways: string[], context: RoutingContext ): Array<{ gateway: string; score: number; reason: string }> { return gateways .map(gateway => { const successRate = this.getSuccessRate(gateway, context); const cost = this.estimateCost(gateway, context); // Weighted score: 70% success rate, 30% cost optimization const score = (successRate * 0.7) + ((1 - cost) * 0.3); return { gateway, score, reason: `score:${score.toFixed(2)}` }; }) .sort((a, b) => b.score - a.score); } private getSuccessRate( gateway: string, context: RoutingContext ): number { // Get historical success rate for this gateway + context // Segment by: card network, issuing country, MCC const key = `${gateway}:${context.cardNetwork}:${context.cardCountry}`; return this.successRates.get(key)?.rate ?? 0.95; } private estimateCost( gateway: string, context: RoutingContext ): number { // Normalized cost (0-1) based on gateway fees + interchange // Lower is better const baseFees: Record<string, number> = { 'stripe': 0.029, 'adyen': 0.025, 'braintree': 0.0259, }; return baseFees[gateway] ?? 0.03; }}A 1% improvement in authorization rate at $1B annual volume = $10M in additional revenue. Smart gateway routing directly impacts the bottom line. This is why enterprise payment platforms invest heavily in routing optimization.
Payment tokenization replaces sensitive card data with a non-sensitive token that can be stored and reused without PCI compliance burden on your systems. This is essential for:
Key Tokenization Concepts:
| Token Type | Scope | Reusability | Use Case |
|---|---|---|---|
| Single-Use Token | One transaction | One-time | One-time payment, checkout form |
| Payment Method Token | Customer-scoped | Reusable | Saved cards, one-click checkout |
| Network Token | Card network-issued | Reusable, updates automatically | Subscriptions, reduces declines |
| Account Updater Token | Auto-updates on card renewal | Long-lived | Reduces involuntary churn |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
interface PaymentMethodToken { id: string; // Your internal ID gatewayToken: string; // Gateway's token reference gateway: string; // Which gateway issued this token customerId: string; // Token owner // Card metadata (non-sensitive) cardBrand: CardBrand; last4: string; expiryMonth: number; expiryYear: number; fingerprint: string; // For duplicate detection // Network tokenization (if available) networkToken?: { type: 'visa' | 'mastercard'; tokenReferenceId: string; }; // State isDefault: boolean; createdAt: Date; lastUsedAt: Date;} class TokenizationService { constructor( private gateways: GatewayFactory, private tokenRepository: TokenRepository ) {} /** * Store a new payment method for a customer */ async tokenize( customerId: string, gatewayToken: string, gateway: string ): Promise<PaymentMethodToken> { // Get card metadata from gateway const gatewayClient = this.gateways.getGateway(gateway); const cardDetails = await gatewayClient.getPaymentMethodDetails(gatewayToken); // Check for duplicates using card fingerprint const existing = await this.tokenRepository.findByFingerprint( customerId, cardDetails.fingerprint ); if (existing) { // Update existing token instead of creating duplicate return this.updateToken(existing.id, gatewayToken); } // Create new token record const token: PaymentMethodToken = { id: generateUuid(), gatewayToken, gateway, customerId, cardBrand: cardDetails.brand, last4: cardDetails.last4, expiryMonth: cardDetails.expMonth, expiryYear: cardDetails.expYear, fingerprint: cardDetails.fingerprint, isDefault: await this.isFirstPaymentMethod(customerId), createdAt: new Date(), lastUsedAt: new Date(), }; await this.tokenRepository.save(token); return token; } /** * Retrieve token for payment processing */ async getToken( tokenId: string, customerId: string ): Promise<PaymentMethodToken> { const token = await this.tokenRepository.findById(tokenId); if (!token) { throw new PaymentMethodNotFoundError(tokenId); } // Security: Verify token belongs to customer if (token.customerId !== customerId) { throw new UnauthorizedPaymentMethodError(); } // Check if token is expired if (this.isTokenExpired(token)) { throw new PaymentMethodExpiredError(token); } return token; } /** * Handle card expiration - called by Account Updater webhook */ async handleCardUpdate( oldFingerprint: string, newCardDetails: CardUpdateDetails ): Promise<void> { const tokens = await this.tokenRepository.findAllByFingerprint( oldFingerprint ); for (const token of tokens) { // Request new token from gateway const gatewayClient = this.gateways.getGateway(token.gateway); const newToken = await gatewayClient.updatePaymentMethod( token.gatewayToken, newCardDetails ); await this.tokenRepository.update(token.id, { gatewayToken: newToken, expiryMonth: newCardDetails.expMonth, expiryYear: newCardDetails.expYear, }); } } private isTokenExpired(token: PaymentMethodToken): boolean { const now = new Date(); const expiry = new Date(token.expiryYear, token.expiryMonth - 1); return now > expiry; }}Network tokens (issued by Visa/Mastercard directly) automatically update when cards are replaced or expire. This can reduce involuntary subscription churn by 2-5%. If your gateway supports network tokenization, enable it for all recurring payment methods.
Payment gateways are external dependencies that can and will fail. Outages, latency spikes, and degraded performance are inevitable. Without proper resilience patterns, a gateway outage becomes your outage.
The Circuit Breaker pattern prevents cascade failures by detecting gateway problems and failing fast rather than waiting for timeouts.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
enum CircuitState { CLOSED, // Normal operation, requests flow through OPEN, // Gateway failed, reject requests immediately HALF_OPEN, // Testing if gateway recovered} interface CircuitBreakerConfig { failureThreshold: number; // Failures before opening (e.g., 5) successThreshold: number; // Successes to close again (e.g., 3) timeout: number; // Time in OPEN before trying again (e.g., 30000ms) volumeThreshold: number; // Min requests before evaluating (e.g., 10) errorPercentageThreshold: number; // e.g., 50 = 50% failure rate} class CircuitBreaker { private state: CircuitState = CircuitState.CLOSED; private failureCount = 0; private successCount = 0; private lastFailureTime: number = 0; private requestCount = 0; constructor( private readonly name: string, private readonly config: CircuitBreakerConfig, private readonly metrics: MetricsClient ) {} isHealthy(): boolean { return this.state !== CircuitState.OPEN; } async execute<T>(operation: () => Promise<T>): Promise<T> { // Check if circuit should transition from OPEN to HALF_OPEN if (this.state === CircuitState.OPEN) { if (Date.now() - this.lastFailureTime > this.config.timeout) { this.transition(CircuitState.HALF_OPEN); } else { throw new CircuitOpenError(this.name); } } this.requestCount++; try { const result = await operation(); this.onSuccess(); return result; } catch (error) { this.onFailure(error); throw error; } } private onSuccess(): void { this.failureCount = 0; if (this.state === CircuitState.HALF_OPEN) { this.successCount++; if (this.successCount >= this.config.successThreshold) { this.transition(CircuitState.CLOSED); } } this.metrics.increment('circuit_breaker.success', { gateway: this.name }); } private onFailure(error: unknown): void { this.failureCount++; this.lastFailureTime = Date.now(); // Only count certain errors as failures if (!this.isCircuitBreakerError(error)) { return; } this.metrics.increment('circuit_breaker.failure', { gateway: this.name }); if (this.state === CircuitState.HALF_OPEN) { // Any failure in half-open goes back to open this.transition(CircuitState.OPEN); return; } // Check if we should open the circuit if (this.requestCount >= this.config.volumeThreshold) { const failureRate = this.failureCount / this.requestCount; if (failureRate >= this.config.errorPercentageThreshold / 100) { this.transition(CircuitState.OPEN); } } } private transition(newState: CircuitState): void { const oldState = this.state; this.state = newState; if (newState === CircuitState.CLOSED) { this.resetCounts(); } if (newState === CircuitState.HALF_OPEN) { this.successCount = 0; } this.metrics.gauge('circuit_breaker.state', newState, { gateway: this.name }); console.log(`Circuit breaker [${this.name}]: ${oldState} -> ${newState}`); } private isCircuitBreakerError(error: unknown): boolean { // Don't trip circuit for business errors (declines, validation) if (error instanceof CardDeclinedError) return false; if (error instanceof ValidationError) return false; // Trip circuit for infrastructure errors if (error instanceof TimeoutError) return true; if (error instanceof NetworkError) return true; if (error instanceof GatewayUnavailableError) return true; return true; // Default to counting as failure } private resetCounts(): void { this.failureCount = 0; this.successCount = 0; this.requestCount = 0; }}Implementing Failover with Circuit Breakers:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
class ResilientPaymentService { private gatewayBreakers: Map<string, CircuitBreaker>; private router: GatewayRouter; async processPayment( request: PaymentRequest ): Promise<PaymentResult> { const routingDecision = this.router.selectGateway({ amount: request.amount, currency: request.currency, paymentMethod: request.paymentMethodType, // ... other context }); const gatewaysToTry = [ routingDecision.primaryGateway, ...routingDecision.fallbackGateways, ]; let lastError: Error | null = null; for (const gatewayName of gatewaysToTry) { const breaker = this.gatewayBreakers.get(gatewayName)!; if (!breaker.isHealthy()) { // Skip unhealthy gateways unless it's our last option if (gatewaysToTry.indexOf(gatewayName) < gatewaysToTry.length - 1) { continue; } } try { const result = await breaker.execute(async () => { const gateway = this.getGateway(gatewayName); return gateway.authorize({ ...request, idempotencyKey: `${request.idempotencyKey}:${gatewayName}`, }); }); // Success! Record which gateway was used return { ...result, gatewayUsed: gatewayName, failoverAttempts: gatewaysToTry.indexOf(gatewayName), }; } catch (error) { lastError = error as Error; // Don't failover on business errors (card declined) if (error instanceof CardDeclinedError) { throw error; } // Log failover attempt this.metrics.increment('payment.failover', { from: gatewayName, error: error.name, }); // Continue to next gateway } } // All gateways failed throw new AllGatewaysFailedError(gatewaysToTry, lastError); }}When failing over to a new gateway, you MUST use a different idempotency key (original_key:gateway_name). Otherwise, if the original request actually succeeded but timed out, the failover request creates a duplicate charge. The original gateway will reject the duplicate when you reconcile later. Each gateway should see a unique idempotency key.
Gateway errors fall into distinct categories requiring different handling strategies. Conflating error types leads to either lost revenue (treating declines as infrastructure failures) or duplicate charges (retrying actual failures).
| Error Type | Examples | Retry? | Failover? | Customer Action |
|---|---|---|---|---|
| Hard Decline | Insufficient funds, card stolen, do not honor | No | No | Try different card |
| Soft Decline | Card velocity limit, temporary block | Yes (later) | Maybe | Wait and retry |
| Validation Error | Invalid card number, expired card | No | No | Correct input |
| Authentication Required | 3DS challenge, SCA required | No | No | Complete challenge |
| Gateway Error | API error, malformed response | Yes (immediately) | Yes | Transparent |
| Network Error | Timeout, connection refused | Yes (with backoff) | Yes | Transparent |
| Rate Limited | Too many requests | Yes (after delay) | Yes | Transparent |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899
// Error classification for retry/failover decisions enum ErrorCategory { HARD_DECLINE, // Don't retry, don't failover SOFT_DECLINE, // Retry later with same gateway VALIDATION_ERROR, // Fix input, don't retry REQUIRES_ACTION, // Customer action needed GATEWAY_ERROR, // Retry immediately with failover NETWORK_ERROR, // Retry with backoff and failover RATE_LIMITED, // Retry after delay} interface ClassifiedError { category: ErrorCategory; code: string; message: string; retryable: boolean; retryAfterMs?: number; failoverAllowed: boolean; userMessage: string;} class GatewayErrorClassifier { // Map gateway-specific error codes to our categories private stripeDeclineCodes: Record<string, ErrorCategory> = { 'insufficient_funds': ErrorCategory.SOFT_DECLINE, 'card_velocity_exceeded': ErrorCategory.SOFT_DECLINE, 'do_not_honor': ErrorCategory.HARD_DECLINE, 'stolen_card': ErrorCategory.HARD_DECLINE, 'fraudulent': ErrorCategory.HARD_DECLINE, 'generic_decline': ErrorCategory.HARD_DECLINE, 'processing_error': ErrorCategory.GATEWAY_ERROR, }; classify(gateway: string, error: GatewayError): ClassifiedError { // Network-level errors if (error instanceof TimeoutError) { return { category: ErrorCategory.NETWORK_ERROR, code: 'timeout', message: 'Gateway request timed out', retryable: true, retryAfterMs: 1000, failoverAllowed: true, userMessage: 'Payment is being processed. Please wait.', }; } if (error instanceof RateLimitError) { return { category: ErrorCategory.RATE_LIMITED, code: 'rate_limited', message: 'Gateway rate limit exceeded', retryable: true, retryAfterMs: error.retryAfter * 1000, failoverAllowed: true, userMessage: 'Too many payment attempts. Please try again shortly.', }; } // Stripe-specific classification if (gateway === 'stripe' && error instanceof StripeCardError) { const category = this.stripeDeclineCodes[error.decline_code] ?? ErrorCategory.HARD_DECLINE; return { category, code: error.decline_code, message: error.message, retryable: category === ErrorCategory.SOFT_DECLINE, retryAfterMs: category === ErrorCategory.SOFT_DECLINE ? 300000 : undefined, // 5 min failoverAllowed: false, userMessage: this.getUserMessage(category, error.decline_code), }; } // Default to gateway error (retryable with failover) return { category: ErrorCategory.GATEWAY_ERROR, code: 'unknown', message: error.message, retryable: true, failoverAllowed: true, userMessage: 'Something went wrong. Please try again.', }; } private getUserMessage(category: ErrorCategory, code: string): string { const messages: Record<string, string> = { 'insufficient_funds': 'Card has insufficient funds. Please try another card.', 'card_velocity_exceeded': 'Too many transactions. Please wait a few minutes.', 'do_not_honor': 'Card was declined. Please contact your bank.', 'stolen_card': 'Card was declined. Please use a different card.', 'expired_card': 'Card has expired. Please update your card details.', }; return messages[code] ?? 'Payment was declined. Please try a different payment method.'; }}Never expose raw gateway error messages to users. 'Card declined: do_not_honor' is confusing. 'Your card was declined. Please try another card or contact your bank.' guides the user to resolution. Map all error codes to actionable user messages.
We've covered the essential patterns for robust payment gateway integration. Let's consolidate:
What's Next:
With gateway integration covered, the next page tackles the most critical design challenge in payment systems: idempotency guarantees. We'll explore why exactly-once processing is essential, implementation patterns for idempotency, and how to handle the timeout/retry scenarios that cause double charges.
You now understand how to architect payment gateway integrations that are resilient, maintainable, and optimized. These patterns form the foundation for reliable payment processing at scale.