Bonaventure OgetoBy Bonaventure Ogeto|

Rate Limits and Caching for Identity Lookups

Cache Resolve Account Number results in your database for 30 days. Cache Resolve Card BIN results indefinitely since BIN data never changes. Use a request queue to throttle outgoing calls to Paystack. Implement a cache-first lookup pattern: check your database before calling the API. For high-traffic applications, add a Redis cache layer in front of the database.

Understanding Paystack Rate Limits

Paystack applies rate limits to all API endpoints, including identity lookups. When you exceed the limit, the API returns a 429 status code with a message telling you to slow down.

Why rate limits exist: Identity endpoints query upstream bank systems. Every Resolve Account Number call goes from Paystack to the destination bank and back. If thousands of merchants hit this endpoint simultaneously without limits, the upstream bank systems would be overwhelmed.

How limits work: Paystack tracks your API calls per time window. The exact numbers depend on your account type and agreement. When you exceed the allowed calls in a window, subsequent calls return 429 until the window resets.

What triggers rate limits in practice:

  • Resolving the same account number repeatedly (user refreshes the page, form resubmits).
  • Batch operations that resolve hundreds of accounts in a loop without delays.
  • Bugs that cause infinite retry loops on failed lookups.
  • Frontend code that calls your resolve endpoint on every keystroke instead of waiting for complete input.

Caching eliminates most of these problems. If you cache properly, you rarely hit rate limits because most lookups resolve from your local storage.

Database Cache Schema

Store resolved results in your database. This is your primary cache layer.

-- Identity lookup cache tables
CREATE TABLE account_resolution_cache (
  id SERIAL PRIMARY KEY,
  account_number TEXT NOT NULL,
  bank_code TEXT NOT NULL,
  account_name TEXT NOT NULL,
  resolved_at TIMESTAMP NOT NULL DEFAULT NOW(),
  expires_at TIMESTAMP NOT NULL DEFAULT (NOW() + INTERVAL '30 days'),
  UNIQUE(account_number, bank_code)
);

CREATE INDEX idx_resolution_lookup
  ON account_resolution_cache(account_number, bank_code)
  WHERE expires_at > NOW();

CREATE TABLE bin_cache (
  bin CHAR(6) PRIMARY KEY,
  brand TEXT NOT NULL,
  bank TEXT,
  card_type TEXT,
  country_code CHAR(2),
  country_name TEXT,
  cached_at TIMESTAMP NOT NULL DEFAULT NOW()
  -- No expiry: BIN data is permanent
);

Account resolution cache expires in 30 days. Account holder names change when people marry, legally change their name, or close and reopen accounts. 30 days is a reasonable window that balances API call savings against data freshness.

BIN cache never expires. The first 6 digits of a card number are permanently assigned to a specific bank and card network. Once cached, the data is valid forever.

Implementing Cache-First Lookups

// cached-identity.js
const axios = require('axios');

async function resolveAccountCached(accountNumber, bankCode) {
  // Step 1: Check database cache
  var cached = await db.query(
    'SELECT account_name, resolved_at FROM account_resolution_cache ' +
    'WHERE account_number = $1 AND bank_code = $2 AND expires_at > NOW()',
    [accountNumber, bankCode]
  );

  if (cached.rows.length > 0) {
    return {
      account_name: cached.rows[0].account_name,
      from_cache: true,
      cached_at: cached.rows[0].resolved_at,
    };
  }

  // Step 2: Call Paystack
  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,
    }
  );

  var accountName = response.data.data.account_name;

  // Step 3: Store in cache
  await db.query(
    'INSERT INTO account_resolution_cache (account_number, bank_code, account_name) ' +
    'VALUES ($1, $2, $3) ' +
    'ON CONFLICT (account_number, bank_code) ' +
    'DO UPDATE SET account_name = $3, resolved_at = NOW(), expires_at = NOW() + INTERVAL '30 days'',
    [accountNumber, bankCode, accountName]
  );

  return {
    account_name: accountName,
    from_cache: false,
  };
}

async function resolveBinCached(bin) {
  // Check cache first
  var cached = await db.query(
    'SELECT brand, bank, card_type, country_code, country_name FROM bin_cache WHERE bin = $1',
    [bin]
  );

  if (cached.rows.length > 0) {
    return cached.rows[0];
  }

  // Call Paystack
  var response = await axios.get(
    'https://api.paystack.co/decision/bin/' + bin,
    { headers: { Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY } }
  );

  var data = response.data.data;

  // Cache permanently
  await db.query(
    'INSERT INTO bin_cache (bin, brand, bank, card_type, country_code, country_name) ' +
    'VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (bin) DO NOTHING',
    [bin, data.brand, data.bank, data.card_type, data.country_code, data.country_name]
  );

  return data;
}

Adding a Redis Cache Layer

For high-traffic applications, add Redis in front of the database. Redis is faster for lookups (microseconds vs milliseconds) and reduces database load.

// redis-cache.js
var Redis = require('ioredis');
var redis = new Redis(process.env.REDIS_URL);

async function resolveAccountWithRedis(accountNumber, bankCode) {
  var cacheKey = 'resolve:' + bankCode + ':' + accountNumber;

  // Check Redis first
  var cached = await redis.get(cacheKey);
  if (cached) {
    return { account_name: cached, from_cache: true };
  }

  // Check database
  var dbCached = await db.query(
    'SELECT account_name FROM account_resolution_cache ' +
    'WHERE account_number = $1 AND bank_code = $2 AND expires_at > NOW()',
    [accountNumber, bankCode]
  );

  if (dbCached.rows.length > 0) {
    // Found in DB, populate Redis for next time
    await redis.setex(cacheKey, 86400, dbCached.rows[0].account_name); // 24h in Redis
    return { account_name: dbCached.rows[0].account_name, from_cache: true };
  }

  // Call Paystack API
  var result = await resolveFromPaystack(accountNumber, bankCode);

  // Store in both Redis and database
  await redis.setex(cacheKey, 86400, result.account_name);
  await storeInDatabase(accountNumber, bankCode, result.account_name);

  return { account_name: result.account_name, from_cache: false };
}

Redis TTL vs database TTL: Use a shorter TTL in Redis (24 hours) and a longer one in the database (30 days). Redis serves as a fast lookup layer. The database is the authoritative cache. If Redis expires, the next lookup hits the database (still fast) rather than Paystack.

When to skip Redis: If you process fewer than 100 identity lookups per hour, a database-only cache is sufficient. Redis adds infrastructure complexity. Only add it when database query latency becomes a bottleneck.

Throttling Outgoing Requests

Even with caching, you may have bursts of new (uncached) lookups. A request queue prevents these bursts from hitting rate limits.

// throttle.js
var queue = [];
var processing = false;
var DELAY_MS = 200; // 5 requests per second max

function enqueueResolve(accountNumber, bankCode) {
  return new Promise(function(resolve, reject) {
    queue.push({ accountNumber: accountNumber, bankCode: bankCode, resolve: resolve, reject: reject });
    processQueue();
  });
}

async function processQueue() {
  if (processing || queue.length === 0) return;
  processing = true;

  while (queue.length > 0) {
    var item = queue.shift();
    try {
      var result = await resolveAccountCached(item.accountNumber, item.bankCode);
      item.resolve(result);
    } catch (error) {
      item.reject(error);
    }

    // Delay between API calls (only for cache misses)
    if (queue.length > 0) {
      await new Promise(function(r) { setTimeout(r, DELAY_MS); });
    }
  }

  processing = false;
}

200ms between calls gives you roughly 5 requests per second. This is well within typical rate limits while still being fast enough for interactive use. For batch operations (resolving hundreds of accounts during a data import), increase the delay to 500ms or more.

The queue is transparent to callers. Code that calls enqueueResolve gets a Promise back and does not know whether the result came from cache or from a queued API call. The throttling is invisible.

Cache Invalidation

Stale cache data causes real problems. If a user's bank account name has changed and your cache still shows the old name, name matching will fail even though the user entered everything correctly.

User-triggered invalidation. Provide a "Re-verify my account" button that bypasses the cache and calls Paystack directly. This is the most important invalidation mechanism.

// invalidate.js
async function forceResolve(accountNumber, bankCode) {
  // Delete from Redis
  var cacheKey = 'resolve:' + bankCode + ':' + accountNumber;
  await redis.del(cacheKey);

  // Delete from database
  await db.query(
    'DELETE FROM account_resolution_cache WHERE account_number = $1 AND bank_code = $2',
    [accountNumber, bankCode]
  );

  // Call Paystack fresh
  return await resolveAccountCached(accountNumber, bankCode);
}

Time-based expiration. The 30-day TTL on the database cache handles routine staleness. After 30 days, the next lookup will call Paystack fresh and update the cache.

Event-triggered invalidation. If a user changes their bank details on your platform, invalidate the cache for their old account and resolve the new one immediately.

Never invalidate BIN cache. BIN data is permanent. There is no scenario where you need to refresh it.

Handling 429 Responses

When you do hit a rate limit despite caching, handle it gracefully.

// handle-429.js
async function resolveWithRateLimit(accountNumber, bankCode) {
  try {
    return await resolveAccountCached(accountNumber, bankCode);
  } catch (error) {
    if (error.response && error.response.status === 429) {
      // Log the rate limit hit
      console.warn('Paystack rate limit hit. Queuing for retry.');

      // Wait and retry once
      await new Promise(function(r) { setTimeout(r, 5000); });

      try {
        return await resolveAccountCached(accountNumber, bankCode);
      } catch (retryError) {
        // Still rate limited - tell the user
        return {
          error: true,
          message: 'Verification is temporarily busy. Please try again in a minute.',
        };
      }
    }

    throw error;
  }
}

Do not retry 429 immediately. Wait at least 5 seconds before retrying. If you retry instantly, you will get another 429.

Monitor 429 frequency. If you see 429 responses regularly, your caching is not working well enough. Investigate: are you caching results properly? Are there code paths that bypass the cache? Is a batch job flooding the endpoint?

Key Takeaways

  • Paystack rate-limits identity endpoints. The exact limits depend on your account and endpoint. When you hit the limit, you get a 429 response.
  • Cache Resolve Account Number results for 30 days. Account holder names change rarely. Store the resolved name, bank code, and timestamp in your database.
  • Cache Resolve Card BIN results indefinitely. A 6-digit BIN always maps to the same bank and card type. This data never changes.
  • Implement a cache-first pattern: check your local cache before calling Paystack. Only call the API on cache misses.
  • Use a request queue to throttle API calls. Even with caching, bursts of new lookups can hit rate limits.
  • Let users manually trigger a cache refresh when their bank details change. Do not force them to wait 30 days for a stale cache to expire.

Frequently Asked Questions

What is the exact rate limit for Paystack identity endpoints?
Paystack does not publicly document exact rate limits, and they may vary by account type and endpoint. Build your integration to work with any limit by implementing proper caching and throttling. If you consistently hit limits, contact your Paystack account manager to discuss your usage patterns.
Can I cache resolved account names in the browser or on the frontend?
Do not cache account names on the frontend. They contain personal data that should stay on your server. Cache them in your backend database or Redis instance, which you control and can secure.
How much database space does the resolution cache need?
Very little. Each row is roughly 100 bytes (account number, bank code, name, timestamps). Even with a million cached resolutions, the table is under 100MB. The BIN cache is even smaller since there are only about 300,000 known BINs globally.
Should I pre-populate the cache with common accounts?
No. Resolve accounts on demand as users submit their details. Pre-populating would require you to have a list of account numbers, which you should not have. Let the cache build naturally from real user activity.

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