Bonaventure OgetoBy Bonaventure Ogeto|

Handling Identity Verification Failures Gracefully

Catch every error from Paystack identity APIs and map it to a user-friendly response. For timeouts and 5xx errors, retry with exponential backoff up to 3 times, then tell the user verification is temporarily unavailable. For 422 errors (account not found), show a clear message asking the user to re-check their details. For rate limit errors, queue the request and try later. Never show raw API errors to users, and never block a user permanently because of a temporary API failure.

Categories of Identity API Failures

Every failure from Paystack identity APIs falls into one of four categories. Each needs a different response.

User errors (4xx except 429). The user sent bad data. Wrong account number, invalid BVN format, missing required fields. The fix is to help the user correct their input.

Rate limits (429). You are calling the API too fast. The fix is to slow down, queue the request, and retry after a delay.

Upstream failures (5xx, timeouts). Paystack or the bank system is having issues. The fix is to retry with backoff, then gracefully degrade if retries fail.

Authentication failures (401). Your API key is wrong or missing. This is a configuration bug, not a user problem. The fix is to check your environment variables.

Treating all failures the same way leads to bad UX. A user who mistyped their account number gets the same "try again later" message as a user who hit a server outage. Handle each category specifically.

Mapping API Errors to User Messages

// error-mapper.js
function mapIdentityError(error) {
  if (!error.response) {
    // Network error or timeout
    if (error.code === 'ECONNABORTED') {
      return {
        userMessage: 'Bank verification is taking longer than usual. Please try again.',
        action: 'retry',
        severity: 'warning',
      };
    }
    return {
      userMessage: 'Could not reach the verification service. Check your internet and try again.',
      action: 'retry',
      severity: 'error',
    };
  }

  var status = error.response.status;

  if (status === 401) {
    return {
      userMessage: 'Verification is temporarily unavailable. Our team has been notified.',
      action: 'alert_team',
      severity: 'critical',
    };
  }

  if (status === 422) {
    var message = error.response.data.message || '';
    if (message.toLowerCase().indexOf('account') !== -1) {
      return {
        userMessage: 'We could not find an account with that number at this bank. Please double-check your account number and bank selection.',
        action: 'user_correction',
        severity: 'info',
      };
    }
    return {
      userMessage: 'The information provided could not be verified. Please check your details and try again.',
      action: 'user_correction',
      severity: 'info',
    };
  }

  if (status === 429) {
    return {
      userMessage: 'We are processing many verification requests. Yours will be completed shortly.',
      action: 'queue',
      severity: 'warning',
    };
  }

  if (status >= 500) {
    return {
      userMessage: 'Bank verification is temporarily unavailable. Please try again in a few minutes.',
      action: 'retry',
      severity: 'warning',
    };
  }

  return {
    userMessage: 'Something went wrong with verification. Please try again.',
    action: 'retry',
    severity: 'error',
  };
}

Never expose raw errors. "Request failed with status code 422" means nothing to a user. "We could not find an account with that number at this bank" tells them exactly what happened and what to do.

Retry Logic with Exponential Backoff

For timeouts and server errors, retry automatically before bothering the user.

// retry-resolve.js
const axios = require('axios');

async function resolveWithRetry(accountNumber, bankCode, maxRetries) {
  maxRetries = maxRetries || 3;
  var lastError;

  for (var attempt = 0; attempt < maxRetries; attempt++) {
    try {
      var response = await axios.get(
        'https://api.paystack.co/bank/resolve',
        {
          params: { account_number: accountNumber, bank_code: bankCode },
          headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY },
          timeout: 15000,
        }
      );
      return { success: true, data: response.data.data };
    } catch (error) {
      lastError = error;

      // Do not retry user errors
      if (error.response && error.response.status >= 400 && error.response.status < 500) {
        break;
      }

      // Wait before retrying: 1s, 2s, 4s
      if (attempt < maxRetries - 1) {
        var delay = Math.pow(2, attempt) * 1000;
        await new Promise(function(resolve) {
          setTimeout(resolve, delay);
        });
      }
    }
  }

  return { success: false, error: mapIdentityError(lastError) };
}

Only retry server errors and timeouts. Never retry a 422 (account not found) or 401 (bad key). Those will fail the same way every time.

Cap retries at 3. Three retries with exponential backoff (1s, 2s, 4s) means a maximum wait of 7 seconds plus the request time. Beyond that, the user should be told to try again later.

Set a reasonable timeout. 15 seconds per request is reasonable for African bank systems, which can be slow. Too short and you get false timeouts. Too long and the user stares at a spinner.

Building a Degraded Mode

When identity APIs are completely down, your application needs a plan. The worst option is to block everything.

Option 1: Save and verify later. Let the user submit their bank details. Store them as "pending verification." Run the verification in a background job when the API comes back. Notify the user when verification completes.

// deferred-verify.js
async function saveForLaterVerification(userId, accountNumber, bankCode) {
  await db.query(
    'INSERT INTO pending_verifications ' +
    '(user_id, account_number, bank_code, status, created_at) ' +
    'VALUES ($1, $2, $3, $4, NOW())',
    [userId, accountNumber, bankCode, 'pending']
  );

  return {
    message: 'Your bank details have been saved. We will verify them shortly and notify you.',
  };
}

// Background job - runs every 5 minutes
async function processUnverifiedAccounts() {
  var pending = await db.query(
    'SELECT * FROM pending_verifications WHERE status = $1 ORDER BY created_at ASC LIMIT 10',
    ['pending']
  );

  for (var i = 0; i < pending.rows.length; i++) {
    var record = pending.rows[i];
    var result = await resolveWithRetry(record.account_number, record.bank_code, 2);

    if (result.success) {
      await db.query(
        'UPDATE pending_verifications SET status = $1, resolved_name = $2 WHERE id = $3',
        ['verified', result.data.account_name, record.id]
      );
      // Notify user
    } else {
      await db.query(
        'UPDATE pending_verifications SET status = $1, retry_count = retry_count + 1 WHERE id = $2',
        ['retry_scheduled', record.id]
      );
    }
  }
}

Option 2: Allow low-value payouts without verification. If the API is down and the user has a strong transaction history, consider allowing payouts below a conservative threshold. This keeps your product functional while managing risk.

Option 3: Show estimated downtime. If you know the API is experiencing widespread issues (check the Paystack status page), tell users: "Bank verification is currently experiencing delays. We expect it to be back within [timeframe]."

Guiding Users Through Failures

How you communicate failures determines whether users retry or abandon your product.

Be specific about what went wrong. "Account not found at this bank" is better than "Verification failed." "Bank system is temporarily busy" is better than "Error."

Tell them what to do next. Every error message should include an action: "Please check your account number and try again." "Try again in a few minutes." "Contact support if this keeps happening."

Show what they entered. When verification fails, show the user their submitted details (account number and bank name) so they can spot typos immediately. A user who sees "Account: 001234567 at GTBank" might instantly realize they meant to type 0001234567.

Preserve their input. Never clear the form on failure. If a user typed 10 digits and selected a bank, keep that data in the form so they can edit rather than re-enter everything.

Offer alternatives. If account resolution keeps failing for a specific bank, suggest: "Having trouble with [bank name]? Some banks experience intermittent verification delays. You can try again later or use a different bank account."

Monitoring Verification Failures

Track failure rates to spot problems before they affect many users.

// monitor.js
async function logVerificationAttempt(endpoint, status, duration, errorCode) {
  await db.query(
    'INSERT INTO verification_metrics ' +
    '(endpoint, status, duration_ms, error_code, created_at) ' +
    'VALUES ($1, $2, $3, $4, NOW())',
    [endpoint, status, duration, errorCode]
  );
}

// Check failure rate every 5 minutes
async function checkFailureRate() {
  var result = await db.query(
    'SELECT ' +
    '  COUNT(*) FILTER (WHERE status = 'error') as failures, ' +
    '  COUNT(*) as total ' +
    'FROM verification_metrics ' +
    'WHERE created_at > NOW() - INTERVAL '5 minutes''
  );

  var row = result.rows[0];
  var failureRate = row.total > 0 ? row.failures / row.total : 0;

  if (failureRate > 0.5 && row.total > 10) {
    // More than 50% failures with meaningful volume - alert the team
    await sendAlert('Identity API failure rate is ' + Math.round(failureRate * 100) + '%');
  }
}

Alert thresholds: A failure rate above 50% with at least 10 requests in the window means something is wrong upstream. A failure rate above 90% means the API is effectively down. Adjust thresholds based on your traffic volume.

Track by bank. Sometimes a single bank's system goes down while others work fine. Tracking failures by bank code helps you tell users "Verification for [bank name] is temporarily unavailable" rather than a generic error.

Testing Your Failure Handling

Most developers test the happy path (verification succeeds) but not the failure paths. Test every error scenario.

Simulate timeouts: Set your HTTP client timeout to 1ms temporarily and verify your retry logic kicks in.

Simulate 422: Send a deliberately wrong account number (like "0000000000") and verify the user sees a helpful message.

Simulate 429: Send many rapid requests and verify your rate limit handling queues rather than crashes.

Simulate network failure: Point the API URL to a non-existent host and verify your network error handling works.

Simulate degraded mode: Disable all API calls and verify your "save and verify later" flow works end to end.

For a comprehensive testing strategy, see the complete testing guide.

Key Takeaways

  • Map every API error to a specific user-facing message. Users should never see raw error codes or stack traces.
  • Timeouts are the most common failure. Set a 15-second timeout on identity API calls and retry up to 3 times with exponential backoff.
  • A 422 from Resolve Account means the account does not exist at that bank. This is user error, not a system failure. Guide the user to re-check their details.
  • Rate limit errors (429) mean you are calling too fast. Queue the request and try again after the rate limit window resets.
  • Build a degraded mode: if identity APIs are completely down, let users save their details and verify later. Do not block them from all product functionality.
  • Log every failure with context (endpoint, error code, user ID, timestamp) for debugging and monitoring.

Frequently Asked Questions

Should I show a loading spinner or a progress message during verification?
Show a spinner with a message: "Verifying your bank account..." If it takes more than 5 seconds, update the message: "This is taking a bit longer than usual. Please wait..." If it fails after retries, replace the spinner with a clear error and a retry button.
What if the same user keeps getting verification failures?
After 3 consecutive failures from the same user for the same account details, suggest they contact support. They may have incorrect details they are not aware of, or there may be an issue with their specific bank. Do not lock them out, but do escalate to human assistance.
Should I cache failed verifications?
Cache 422 results (account not found) for a short period (5 minutes) to avoid hammering the API with the same bad input. Do not cache timeout or 5xx failures, because those are temporary and the next attempt may succeed.
How do I test identity API failures in a staging environment?
Use test keys, which return predictable responses. For error scenarios, mock the Paystack API with a test server that returns specific error codes on demand. Tools like nock (Node.js) or WireMock let you simulate any HTTP response.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon