Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.zuba.com/llms.txt

Use this file to discover all available pages before exploring further.

Robust error handling is crucial for payment systems. This guide covers common error scenarios, proper error handling strategies, and recovery mechanisms for both payouts and payins.

Common Error Types

API Errors

HTTP status codes and their meanings:
const ERROR_CODES = {
  400: 'Bad Request - Invalid data or missing required fields',
  401: 'Unauthorized - Invalid or missing API key',  
  403: 'Forbidden - Insufficient permissions',
  404: 'Not Found - Resource does not exist',
  409: 'Conflict - Resource already exists or state conflict',
  422: 'Unprocessable Entity - Validation errors',
  429: 'Too Many Requests - Rate limit exceeded',
  500: 'Internal Server Error - Server-side error',
  502: 'Bad Gateway - Upstream service error',
  503: 'Service Unavailable - Temporary service outage'
};

Business Logic Errors

Payment-specific error scenarios:
  • Insufficient funds - Not enough balance for payout
  • Invalid beneficiary - Beneficiary doesn’t exist or is inactive
  • Currency mismatch - Unsupported currency combination
  • Route unavailable - Payment route not available for destination
  • Amount limits - Payment exceeds minimum/maximum limits
  • Compliance issues - KYC, sanctions, or regulatory blocks

Error Response Structure

Zuba API returns structured error responses:
{
  "error": {
    "code": "INSUFFICIENT_FUNDS",
    "message": "Insufficient funds to process payout",
    "details": {
      "availableBalance": 150.00,
      "requestedAmount": 200.00,
      "currency": "EUR"
    },
    "timestamp": "2024-01-15T10:30:00Z",
    "requestId": "req_123e4567e89b12d3"
  }
}

Implementing Error Handling

Basic Error Handler

Create a centralized error handling utility:
class ZubaAPIError extends Error {
  constructor(status, code, message, details) {
    super(message);
    this.name = 'ZubaAPIError';
    this.status = status;
    this.code = code;
    this.details = details;
  }
}

async function handleZubaResponse(response) {
  if (response.ok) {
    return await response.json();
  }

  const error = await response.json().catch(() => ({}));
  
  throw new ZubaAPIError(
    response.status,
    error.error?.code || 'UNKNOWN_ERROR',
    error.error?.message || `HTTP ${response.status} error`,
    error.error?.details || {}
  );
}

// Usage
try {
  const response = await fetch('https://api.zuba.com/v1/payouts', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer YOUR_API_TOKEN'
    },
    body: JSON.stringify(payoutData)
  });

  const payout = await handleZubaResponse(response);
  return payout;
} catch (error) {
  if (error instanceof ZubaAPIError) {
    console.error(`API Error [${error.code}]:`, error.message);
    console.error('Details:', error.details);
  } else {
    console.error('Network error:', error.message);
  }
  throw error;
}

Specific Error Handlers

Handle different error types appropriately:
class PayoutErrorHandler {
  static handle(error) {
    if (!(error instanceof ZubaAPIError)) {
      return this.handleNetworkError(error);
    }

    switch (error.code) {
      case 'INSUFFICIENT_FUNDS':
        return this.handleInsufficientFunds(error);
      
      case 'INVALID_BENEFICIARY':
        return this.handleInvalidBeneficiary(error);
      
      case 'CURRENCY_NOT_SUPPORTED':
        return this.handleUnsupportedCurrency(error);
      
      case 'AMOUNT_TOO_LOW':
      case 'AMOUNT_TOO_HIGH':
        return this.handleAmountLimits(error);
      
      case 'ROUTE_UNAVAILABLE':
        return this.handleRouteUnavailable(error);
      
      default:
        return this.handleGenericError(error);
    }
  }

  static handleInsufficientFunds(error) {
    const { availableBalance, requestedAmount, currency } = error.details;
    
    return {
      userMessage: `Insufficient balance. Available: ${availableBalance} ${currency}, Requested: ${requestedAmount} ${currency}`,
      suggestion: 'Please top up your account or reduce the payout amount',
      canRetry: false,
      requiresAction: 'TOPUP_BALANCE'
    };
  }

  static handleInvalidBeneficiary(error) {
    return {
      userMessage: 'The recipient is invalid or inactive',
      suggestion: 'Please verify the beneficiary details and try again',
      canRetry: false,
      requiresAction: 'UPDATE_BENEFICIARY'
    };
  }

  static handleUnsupportedCurrency(error) {
    const { requestedCurrency, supportedCurrencies } = error.details;
    
    return {
      userMessage: `Currency ${requestedCurrency} is not supported`,
      suggestion: `Supported currencies: ${supportedCurrencies?.join(', ')}`,
      canRetry: false,
      requiresAction: 'CHANGE_CURRENCY'
    };
  }

  static handleAmountLimits(error) {
    const { minAmount, maxAmount, currency } = error.details;
    
    return {
      userMessage: `Amount must be between ${minAmount} and ${maxAmount} ${currency}`,
      suggestion: 'Please adjust the payout amount',
      canRetry: false,
      requiresAction: 'ADJUST_AMOUNT'
    };
  }

  static handleRouteUnavailable(error) {
    const { requestedRoute, availableRoutes } = error.details;
    
    return {
      userMessage: `Payment route ${requestedRoute} is currently unavailable`,
      suggestion: `Try using: ${availableRoutes?.join(', ')}`,
      canRetry: true,
      requiresAction: 'CHANGE_ROUTE'
    };
  }

  static handleNetworkError(error) {
    return {
      userMessage: 'Network error occurred',
      suggestion: 'Please check your connection and try again',
      canRetry: true,
      requiresAction: 'RETRY'
    };
  }

  static handleGenericError(error) {
    return {
      userMessage: 'An error occurred while processing your request',
      suggestion: 'Please try again or contact support',
      canRetry: true,
      requiresAction: 'CONTACT_SUPPORT'
    };
  }
}

Retry Strategies

Exponential Backoff

Implement intelligent retry logic:
class RetryHandler {
  constructor(maxRetries = 3, baseDelay = 1000) {
    this.maxRetries = maxRetries;
    this.baseDelay = baseDelay;
  }

  async executeWithRetry(fn, context = {}) {
    let lastError;

    for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
      try {
        return await fn();
      } catch (error) {
        lastError = error;

        // Don't retry certain errors
        if (!this.shouldRetry(error, attempt)) {
          throw error;
        }

        if (attempt < this.maxRetries) {
          const delay = this.calculateDelay(attempt);
          console.log(`Retry ${attempt} in ${delay}ms for ${context.operation || 'operation'}`);
          await this.sleep(delay);
        }
      }
    }

    throw lastError;
  }

  shouldRetry(error, attempt) {
    // Don't retry client errors (4xx) except rate limiting
    if (error instanceof ZubaAPIError) {
      if (error.status >= 400 && error.status < 500) {
        return error.status === 429; // Rate limit
      }
      
      // Retry server errors (5xx) and network errors
      return error.status >= 500 || !error.status;
    }

    // Retry network errors
    return true;
  }

  calculateDelay(attempt) {
    // Exponential backoff with jitter
    const exponentialDelay = this.baseDelay * Math.pow(2, attempt - 1);
    const jitter = Math.random() * 0.1 * exponentialDelay;
    return Math.round(exponentialDelay + jitter);
  }

  sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

// Usage
const retryHandler = new RetryHandler(3, 1000);

async function createPayoutWithRetry(payoutData) {
  return await retryHandler.executeWithRetry(
    () => createPayout(payoutData),
    { operation: 'createPayout' }
  );
}

Circuit Breaker Pattern

Prevent cascading failures:
class CircuitBreaker {
  constructor(threshold = 5, timeout = 60000) {
    this.failureThreshold = threshold;
    this.resetTimeout = timeout;
    this.failureCount = 0;
    this.lastFailureTime = null;
    this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
  }

  async execute(fn) {
    if (this.state === 'OPEN') {
      if (Date.now() - this.lastFailureTime > this.resetTimeout) {
        this.state = 'HALF_OPEN';
      } else {
        throw new Error('Circuit breaker is OPEN');
      }
    }

    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure(error);
      throw error;
    }
  }

  onSuccess() {
    this.failureCount = 0;
    this.state = 'CLOSED';
  }

  onFailure(error) {
    this.failureCount++;
    this.lastFailureTime = Date.now();

    if (this.failureCount >= this.failureThreshold) {
      this.state = 'OPEN';
    }
  }
}

// Usage
const circuitBreaker = new CircuitBreaker(5, 60000);

async function createPayoutWithCircuitBreaker(payoutData) {
  return await circuitBreaker.execute(() => createPayout(payoutData));
}

Validation Errors

Client-Side Validation

Validate data before API calls:
class PayoutValidator {
  static validate(payoutData) {
    const errors = [];

    // ClientRef validation
    if (!payoutData.clientRef) {
      errors.push('clientRef is required for idempotency');
    }

    // Amount validation (amount should be a string)
    const amount = parseFloat(payoutData.amount);
    if (!payoutData.amount || isNaN(amount) || amount <= 0) {
      errors.push('Amount must be a positive number (as string)');
    }

    if (amount > 10000) {
      errors.push('Amount exceeds maximum limit of 10,000');
    }

    // Currency validation
    const supportedCurrencies = ['EUR', 'USD', 'GBP', 'NGN'];
    if (!supportedCurrencies.includes(payoutData.currency)) {
      errors.push(`Currency must be one of: ${supportedCurrencies.join(', ')}`);
    }

    // Beneficiary validation (must be an object with id)
    if (!payoutData.beneficiary?.id || !this.isValidUUID(payoutData.beneficiary.id)) {
      errors.push('Valid beneficiary.id is required');
    }

    // Route validation
    const supportedRoutes = ['sepa_inst', 'sepa_credit', 'bank_transfer'];
    if (!supportedRoutes.includes(payoutData.route)) {
      errors.push(`Route must be one of: ${supportedRoutes.join(', ')}`);
    }

    return errors;
  }

  static isValidUUID(str) {
    const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
    return uuidRegex.test(str);
  }
}

// Usage
try {
  const errors = PayoutValidator.validate(payoutData);
  if (errors.length > 0) {
    throw new Error(`Validation failed: ${errors.join(', ')}`);
  }

  const payout = await createPayout(payoutData);
} catch (error) {
  console.error('Validation error:', error.message);
}

Status Monitoring

Polling with Error Handling

Monitor payment status with proper error handling:
class PaymentMonitor {
  constructor(apiToken, options = {}) {
    this.apiToken = apiToken;
    this.pollInterval = options.pollInterval || 5000;
    this.maxPollTime = options.maxPollTime || 300000; // 5 minutes
    this.retryHandler = new RetryHandler();
  }

  async waitForCompletion(paymentId, type = 'payout') {
    const startTime = Date.now();
    let lastKnownStatus = 'unknown';

    while (Date.now() - startTime < this.maxPollTime) {
      try {
        const payment = await this.retryHandler.executeWithRetry(
          () => this.checkStatus(paymentId, type)
        );

        lastKnownStatus = payment.status;

        // Terminal states
        if (['paid', 'completed', 'failed', 'cancelled'].includes(payment.status)) {
          return payment;
        }

        await this.sleep(this.pollInterval);

      } catch (error) {
        console.error(`Error checking payment ${paymentId}:`, error.message);
        
        // Continue polling unless it's a permanent error
        if (error instanceof ZubaAPIError && error.status === 404) {
          throw new Error(`Payment ${paymentId} not found`);
        }

        await this.sleep(this.pollInterval);
      }
    }

    throw new Error(`Payment ${paymentId} timeout (last status: ${lastKnownStatus})`);
  }

  async checkStatus(paymentId, type) {
    const endpoint = type === 'payout' 
      ? `/v1/payouts/${paymentId}`
      : `/v1/open-banking/payments/${paymentId}`;

    const response = await fetch(`https://api.zuba.com${endpoint}`, {
      headers: {
        'Authorization': `Bearer ${this.apiToken}`
      }
    });

    return await handleZubaResponse(response);
  }

  sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

// Usage
const monitor = new PaymentMonitor(process.env.ZUBA_API_TOKEN);

try {
  const completedPayment = await monitor.waitForCompletion(paymentId);
  console.log('Payment completed:', completedPayment.status);
} catch (error) {
  console.error('Payment monitoring failed:', error.message);
}

Error Recovery

Automatic Recovery Actions

Implement automatic recovery for certain error types:
class PaymentRecovery {
  constructor(apiService) {
    this.api = apiService;
  }

  async recoverFromError(error, originalRequest) {
    if (!(error instanceof ZubaAPIError)) {
      throw error; // Can't recover from network errors automatically
    }

    switch (error.code) {
      case 'ROUTE_UNAVAILABLE':
        return await this.tryAlternativeRoute(originalRequest);
      
      case 'AMOUNT_TOO_HIGH':
        return await this.splitLargePayment(originalRequest);
      
      case 'BENEFICIARY_INACTIVE':
        return await this.reactivateBeneficiary(originalRequest);
      
      default:
        throw error; // No recovery strategy available
    }
  }

  async tryAlternativeRoute(request) {
    const routePriority = {
      'sepa_inst': ['sepa_credit'],
      'sepa_credit': ['stripe'],
      'stripe': ['sepa_credit']
    };

    const alternatives = routePriority[request.route] || [];
    
    for (const altRoute of alternatives) {
      try {
        const modifiedRequest = { ...request, route: altRoute };
        return await this.api.createPayout(modifiedRequest);
      } catch (error) {
        console.log(`Alternative route ${altRoute} also failed:`, error.message);
      }
    }

    throw new Error('All payment routes failed');
  }

  async splitLargePayment(request) {
    const { amount, ...restRequest } = request;
    const maxChunkSize = 5000; // Example limit
    
    if (amount <= maxChunkSize) {
      throw new Error('Cannot split payment further');
    }

    const chunks = Math.ceil(amount / maxChunkSize);
    const chunkSize = amount / chunks;
    const payouts = [];

    for (let i = 0; i < chunks; i++) {
      const chunkAmount = i === chunks - 1 
        ? amount - (chunkSize * i) // Last chunk gets remainder
        : chunkSize;

      const chunkRequest = {
        ...restRequest,
        amount: chunkAmount,
        reference: `${request.reference}-chunk-${i + 1}`
      };

      const payout = await this.api.createPayout(chunkRequest);
      payouts.push(payout);
    }

    return payouts;
  }
}

Comprehensive Error Handler

Put it all together in a comprehensive solution:
class ZubaPaymentService {
  constructor(apiToken) {
    this.apiToken = apiToken;
    this.baseURL = 'https://api.zuba.com/v1';
    this.retryHandler = new RetryHandler();
    this.circuitBreaker = new CircuitBreaker();
    this.recovery = new PaymentRecovery(this);
  }

  async createPayout(payoutData) {
    try {
      // 1. Validate input
      const validationErrors = PayoutValidator.validate(payoutData);
      if (validationErrors.length > 0) {
        throw new Error(`Validation failed: ${validationErrors.join(', ')}`);
      }

      // 2. Execute with retry and circuit breaker
      const payout = await this.circuitBreaker.execute(async () => {
        return await this.retryHandler.executeWithRetry(async () => {
          return await this._createPayoutInternal(payoutData);
        });
      });

      return payout;

    } catch (error) {
      // 3. Attempt recovery
      try {
        const recovered = await this.recovery.recoverFromError(error, payoutData);
        console.log('Payment recovered using alternative method');
        return recovered;
      } catch (recoveryError) {
        // 4. Handle final error
        const errorInfo = PayoutErrorHandler.handle(error);
        
        // Log for monitoring
        this.logError(error, payoutData, errorInfo);
        
        // Re-throw with user-friendly information
        throw new PaymentError(errorInfo.userMessage, {
          originalError: error,
          suggestion: errorInfo.suggestion,
          canRetry: errorInfo.canRetry,
          requiresAction: errorInfo.requiresAction
        });
      }
    }
  }

  async _createPayoutInternal(payoutData) {
    const response = await fetch(`${this.baseURL}/payouts`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${this.apiToken}`
      },
      body: JSON.stringify(payoutData)
    });

    return await handleZubaResponse(response);
  }

  logError(error, requestData, errorInfo) {
    console.error('Payment error:', {
      error: error.message,
      code: error.code,
      request: requestData,
      userMessage: errorInfo.userMessage,
      canRetry: errorInfo.canRetry,
      timestamp: new Date().toISOString()
    });
  }
}

class PaymentError extends Error {
  constructor(message, details) {
    super(message);
    this.name = 'PaymentError';
    this.details = details;
  }
}

// Usage
const paymentService = new ZubaPaymentService(process.env.ZUBA_API_TOKEN);

try {
  const payout = await paymentService.createPayout(payoutData);
  console.log('Payout created successfully:', payout.id);
} catch (error) {
  if (error instanceof PaymentError) {
    console.error('Payment failed:', error.message);
    console.log('Suggestion:', error.details.suggestion);
    
    if (error.details.canRetry) {
      // Show retry button to user
    }
  }
}

Best Practices

  1. Always validate input before making API calls
  2. Use structured error responses with clear user messages
  3. Implement retry logic with exponential backoff
  4. Log errors comprehensively for debugging and monitoring
  5. Provide recovery options when possible
  6. Use circuit breakers to prevent cascading failures
  7. Monitor error rates and set up alerts
  8. Test error scenarios thoroughly in development
  9. Have fallback options for critical payment flows
  10. Keep error messages user-friendly while logging technical details

Next Steps