Skip to main content

Troubleshooting Visa Direct API Error Codes 9100 & 9615

 Integrating instant payouts via the Visa Direct API is rarely a "happy path" endeavor. While the documentation covers the basics of a successful Original Credit Transaction (OCT), it often leaves developers stranded when edge cases arise.

Two of the most notorious offenders in the Visa Direct ecosystem are response codes 9100 and 9615.

If you are seeing these errors, you are likely dealing with ambiguous failures that standard HTTP retry logic cannot solve. Error 9100 implies a dangerous state where the transaction status is unknown, raising the risk of double-spending. Error 9615 often looks like a data entry error but is actually a fundamental issue with card capabilities.

This guide provides the root cause analysis for these errors and a production-grade TypeScript implementation to handle them safely.

Root Cause Analysis: The Architecture of Failure

To fix these errors, we must first understand where they originate in the payment lifecycle. Visa Direct operations do not occur in a vacuum; they traverse the acquirer, the VisaNet VIP (Visa Integrated Payments) system, and the issuing bank.

The Truth About Error 9100 (Generic System Error)

9100 code is not a standard HTTP 500. It is an application-level timeout or failure occurring downstream from the Visa Direct edge.

When you receive a 9100, one of three things has happened:

  1. VisaNet Switch Timeout: Visa received your request but couldn't get a response from the Issuer within the hard time limit (usually a few seconds).
  2. Database Lock: A race condition occurred in the settlement ledger.
  3. Issuer Unavailability: The receiving bank is offline for maintenance.

The Danger: The transaction might have actually succeeded. If you simply generate a new retrievalReferenceNumber (RRN) and try again, you risk crediting the user twice. You must implement idempotent retries using the original RRN.

The Truth About Error 9615 (Invalid Card)

Developers often assume 9615 means the user typed their credit card number incorrectly (Luhn check failure). However, in the context of Push-to-Card, this usually means "Card Ineligible for OCT."

The Primary Account Number (PAN) exists, but the Issuer has blocked "Fast Funds" or "Push Payments" for this specific BIN range. This is common with prepaid cards, corporate expense cards, or cards issued in specific regulatory regions.

The Solution: Idempotent Retry Strategy

To handle code 9100 safely, we need an executePayout function that implements distinct logic for network failures vs. application failures.

We will use TypeScript and the native fetch API (available in Node.js 18+) to create a wrapper that handles the 9100 idempotency requirement.

Prerequisites

Ensure your payload includes a retrievalReferenceNumber (RRN). This is your idempotency key. Visa uses the RRN + System Trace Audit Number (STAN) + Date to de-duplicate requests.

The Implementation

import { randomUUID, randomInt } from 'node:crypto';

/**
 * Standard Visa Direct API Response shape
 */
interface VisaDirectResponse {
  responseStatus: {
    code: string;
    severity: string;
    message: string;
    info?: string;
  };
  actionCode?: string; // ISO 8583 response code
  transactionIdentifier?: string;
}

interface PayoutRequest {
  amount: number;
  currency: string;
  recipientPan: string;
  retrievalReferenceNumber: string; // Crucial for 9100 retries
  stan: string; // System Trace Audit Number
}

// Configuration for retry logic
const MAX_RETRIES = 3;
const BASE_DELAY_MS = 1000;

/**
 * Sleep utility for backoff
 */
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

/**
 * Execute Payout with 9100 Handling
 */
async function executePayout(payload: PayoutRequest, attempt = 1): Promise<VisaDirectResponse> {
  const url = 'https://sandbox.api.visa.com/visadirect/fundstransfer/v1/pushfundstransactions';
  
  // In production, these headers must include Mutual TLS (mTLS) certs and Auth
  const headers = {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
    // 'X-Client-Transaction-Id' is often mapped to your internal request ID
    'X-Client-Transaction-Id': payload.retrievalReferenceNumber 
  };

  try {
    const response = await fetch(url, {
      method: 'POST',
      headers,
      body: JSON.stringify(payload),
    });

    const data = (await response.json()) as VisaDirectResponse;

    // Check for Error 9100 (System Error)
    if (data.responseStatus.code === '9100') {
      if (attempt <= MAX_RETRIES) {
        // Calculate Exponential Backoff with Jitter
        const jitter = randomInt(0, 500);
        const delay = (BASE_DELAY_MS * Math.pow(2, attempt - 1)) + jitter;

        console.warn(`[Payout] Received 9100 for RRN ${payload.retrievalReferenceNumber}. Retrying in ${delay}ms... (Attempt ${attempt})`);
        
        await sleep(delay);
        
        // RECURSIVE RETRY: Must pass the EXACT SAME payload (same RRN/STAN)
        return executePayout(payload, attempt + 1);
      } else {
        throw new Error(`Transaction Indeterminate: Max retries exhausted for 9100 on RRN ${payload.retrievalReferenceNumber}. Manual reconciliation required.`);
      }
    }

    return data;

  } catch (error) {
    // Handle Network interruptions (e.g., DNS failures, TCP timeouts)
    // These are distinct from API 9100 responses but should also trigger retries
    // if we haven't received a response payload.
    if (attempt <= MAX_RETRIES && error instanceof TypeError) {
       console.error(`[Payout] Network failure: ${error.message}. Retrying...`);
       await sleep(BASE_DELAY_MS);
       return executePayout(payload, attempt + 1);
    }
    throw error;
  }
}

// Usage Example
(async () => {
  const payoutPayload: PayoutRequest = {
    amount: 100.00,
    currency: 'USD',
    recipientPan: '4000001234567890',
    // Generate these ONCE per transaction attempt
    retrievalReferenceNumber: new Date().toISOString().replace(/\D/g, '').slice(0, 12),
    stan: randomInt(100000, 999999).toString()
  };

  try {
    const result = await executePayout(payoutPayload);
    
    if (result.actionCode === '00') {
      console.log('Success:', result.transactionIdentifier);
    } else if (result.responseStatus.code === '9615') {
       console.error('Fatal: Card not eligible for Fast Funds.');
    } else {
      console.error('Declined:', result.responseStatus.message);
    }
  } catch (err) {
    console.error('Critical Failure:', err);
    // Queue for manual review / Reconciliation System
  }
})();

Solving Error 9615: Pre-Flight Validation

Retry logic will not solve a 9615 error. Retrying an invalid card simply results in ban-hammering by your acquirer for excessive declines.

The solution is to perform an Account Attribute Inquiry (AAI) before attempting the money transfer. In the Visa ecosystem, this checks the fastFundsIndicator.

The "Fast Funds" Check Logic

You must look for the fastFundsIndicator in the AAI response. It generally returns a single character code.

  • 'Y': Eligible for OCT (Push to Card) and Fast Funds (30-minute availability).
  • 'N': Not eligible. Do not attempt the payout.
  • 'C': Eligible for OCT, but no Fast Funds guarantee (standard settlement times).

Here is how to structure the validation logic:

/**
 * Validate Card Capabilities before Payout
 * Simulates checking against Visa's PAN Attribute Inquiry
 */
function isCardEligibleForPayout(attributesResponse: any): boolean {
  const fastFunds = attributesResponse?.fastFundsIndicator;
  const pushToCard = attributesResponse?.pushToCardEligible; // Some regions use this field

  // Strict Mode: Only allow cards that support real-time settlement
  if (fastFunds === 'Y') {
    return true;
  }

  // Permissive Mode: Allow OCT even if settlement is slow (e.g., 'C')
  // Warning: This may result in user support tickets regarding delay
  if (fastFunds === 'C' || pushToCard === 'Y') {
    return true;
  }

  // If we get here, a Payout attempt will likely result in 9615
  return false;
}

Deep Dive: Why The RRN Matters

In the code example above, the most critical line is: 'X-Client-Transaction-Id': payload.retrievalReferenceNumber

When VisaNet receives a request with an RRN it has seen in the last 24 hours, it checks the status.

  1. If the previous attempt is still processing, Visa returns a "In Progress" signal.
  2. If the previous attempt succeeded, Visa returns the saved success response immediately without charging the card again.
  3. If the previous attempt failed, Visa attempts the logic again.

If you generate a new RRN for a retry on a 9100 error, you break this chain of custody. If the first request actually succeeded but timed out returning the byte stream to your server, the second request (with the new RRN) will post a second credit to the user's account.

Common Pitfalls and Edge Cases

1. The "Soft" 9615

Occasionally, a 9615 occurs because the card is a "Combo Card" (Credit + Debit). If you are defaulting to the sourceOfFunds as 01 (Visa Check/Debit), try switching to 02 (Credit) if you know the BIN range supports it. However, the AAI check is preferred over guessing.

2. Manual Reconciliation Queue

If your code throws the Transaction Indeterminate error (max retries exceeded), do not fail the transaction in your database. Instead, move it to a "Pending Reconciliation" state. Your system must poll the GetTransactionStatus API (if available in your region) or wait for the daily settlement report (Raw Data File) to confirm finality before unlocking those funds or retrying manually.

3. Region Specifics

In Europe (PSD2 zone), a 9615 might also trigger if Strong Customer Authentication (SCA) data was missing from the initial verification, rendering the card "invalid for this specific unauthenticated flow." Ensure your payload includes AVS (Address Verification) data to improve acceptance rates.

Conclusion

Handling Visa Direct errors requires a shift in mindset from "Retry everything" to "Retry selectively."

  • For 9100: Use exponential backoff with a persistent retrievalReferenceNumber to ensure idempotency.
  • For 9615: Stop the transaction flow immediately. Implement an Account Attribute Inquiry step upstream to filter out ineligible cards before they ever hit your payout logic.

By implementing these rigorous checks, you reduce decline rates, protect your treasury from duplicate payouts, and significantly reduce the operational overhead of manual payment investigations.