Bonaventure OgetoBy Bonaventure Ogeto|

Paystack API Timeouts and Retry Strategy

Paystack API timeouts happen on the client side, not on Paystack. Your HTTP client gave up waiting before the server responded. Set your timeout to 15-30 seconds (not the default 5 seconds many libraries use). Use AbortController in Node.js to enforce the timeout. For retries, only retry idempotent endpoints like /transaction/verify and /transfer/verify. Never retry /transaction/initialize without checking whether the first request actually went through, because Paystack may have created the transaction even though your client timed out. Use exponential backoff with jitter: wait 1 second, then 2 seconds, then 4 seconds, plus a random 0-500ms.

What a Paystack API Timeout Actually Means

When you get a timeout error calling Paystack, it does not mean Paystack is down. It means your HTTP client waited for a response, hit its time limit, and gave up. The important thing: Paystack may have received and processed your request. You just never got the response.

This is what a timeout looks like in Node.js:

// With fetch + AbortController
AbortError: The operation was aborted

// With axios
Error: timeout of 5000ms exceeded

// With node-fetch
FetchError: network timeout at: https://api.paystack.co/transaction/initialize

The danger is acting on the timeout as if the request failed. If you called /transaction/initialize and timed out, the transaction may exist on Paystack. If you retry, you create a second transaction. If you called /transaction/charge_authorization and timed out, the charge may have gone through. Retrying charges the customer twice.

Timeouts are not the same as errors. An error (4xx, 5xx) means Paystack received your request and rejected it. A timeout means you have no idea what happened on the server side.

Why Timeouts Happen

There are four common causes of Paystack API timeouts.

1. Your client timeout is too short. Many HTTP libraries default to 5 seconds. Paystack API calls that involve bank communication (initialize, charge, verify during processing) can take 10-20 seconds. A 5-second timeout will cut the connection before the bank responds.

2. Your server is behind a proxy with its own timeout. Nginx defaults to 60 seconds, but some configurations set it lower. AWS API Gateway defaults to 29 seconds. If your proxy times out before your Node.js client does, you get a 504 Gateway Timeout from the proxy, not from Paystack.

3. DNS resolution is slow. If your server cannot resolve api.paystack.co quickly (common on some VPS providers or Docker containers without proper DNS config), the DNS lookup eats into your timeout budget. A 5-second timeout with a 3-second DNS lookup leaves only 2 seconds for the actual API call.

4. Paystack is experiencing high load. During peak periods (month-end salary payments, Black Friday sales), Paystack response times increase. A timeout value that works at 2am may not work at noon on payday. Check status.paystack.com if timeouts suddenly spike.

Setting Proper Timeout Values with AbortController

Use AbortController to add timeout behavior to Node.js fetch calls. The native fetch API does not have a timeout option, so you create an abort signal that fires after your chosen duration.

// lib/paystack-client.js

function createPaystackRequest(endpoint, options) {
  var timeout = options.timeout || 30000; // 30 seconds default
  var controller = new AbortController();
  var timeoutId = setTimeout(function() {
    controller.abort();
  }, timeout);

  var url = 'https://api.paystack.co' + endpoint;

  var fetchOptions = {
    method: options.method || 'GET',
    headers: {
      'Authorization': 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      'Content-Type': 'application/json',
    },
    signal: controller.signal,
  };

  if (options.body) {
    fetchOptions.body = JSON.stringify(options.body);
  }

  return fetch(url, fetchOptions)
    .then(function(res) {
      clearTimeout(timeoutId);
      return res.json();
    })
    .catch(function(err) {
      clearTimeout(timeoutId);
      if (err.name === 'AbortError') {
        var error = new Error(
          'Paystack API timeout after ' + timeout + 'ms on ' + endpoint
        );
        error.code = 'PAYSTACK_TIMEOUT';
        error.endpoint = endpoint;
        error.timeout = timeout;
        throw error;
      }
      throw err;
    });
}

// Usage
createPaystackRequest('/transaction/verify/' + reference, {
  method: 'GET',
  timeout: 15000,  // 15 seconds for verify
});

createPaystackRequest('/transaction/initialize', {
  method: 'POST',
  body: { email: email, amount: amount },
  timeout: 30000,  // 30 seconds for initialize
});

Recommended timeout values by endpoint type:

  • Initialize (POST /transaction/initialize): 30 seconds. This endpoint talks to banks for card tokenization.
  • Verify (GET /transaction/verify): 15 seconds. This is a read operation against Paystack's database.
  • Charge authorization (POST /transaction/charge_authorization): 30 seconds. Involves bank communication.
  • Transfers (POST /transfer): 30 seconds. Bank processing times vary.
  • List/Fetch endpoints: 15 seconds. These are database reads.

Which Endpoints Are Safe to Retry

The word "idempotent" means calling the endpoint twice produces the same result as calling it once. For Paystack:

Safe to retry (idempotent):

  • GET /transaction/verify/:reference - Reading data, no side effects
  • GET /transaction/:id - Reading data
  • GET /transfer/verify/:reference - Reading data
  • GET /customer/:email_or_code - Reading data
  • All GET list endpoints - Reading data

NOT safe to retry blindly:

  • POST /transaction/initialize - Creates a new transaction each time. Retrying creates duplicates.
  • POST /transaction/charge_authorization - Charges the customer. Retrying double-charges.
  • POST /transfer - Sends money. Retrying sends money twice.
  • POST /refund - Refunds money. Retrying may double-refund.

Safe to retry WITH your own idempotency key:

  • POST /transaction/initialize - Safe if you use the same reference. Paystack will reject the second call with "Duplicate Transaction Reference" instead of creating a new transaction.
  • POST /transfer - Safe if you use the same reference. Paystack rejects duplicates.

The pattern: always generate your reference before calling the API, not after. That way, if you need to retry, you send the same reference and Paystack's duplicate detection protects you.

Retry with Exponential Backoff and Jitter

When you retry, do not fire the second request immediately. Use exponential backoff: wait longer between each attempt. Add jitter (randomness) so that if 50 customers all time out at the same moment, they do not all retry at the same moment and overload Paystack again.

// lib/retry.js

function wait(ms) {
  return new Promise(function(resolve) {
    setTimeout(resolve, ms);
  });
}

function calculateBackoff(attempt, baseDelay, maxDelay) {
  // Exponential: 1s, 2s, 4s, 8s...
  var exponentialDelay = baseDelay * Math.pow(2, attempt);
  // Cap at maxDelay
  var capped = Math.min(exponentialDelay, maxDelay);
  // Add jitter: random 0 to 500ms
  var jitter = Math.floor(Math.random() * 500);
  return capped + jitter;
}

function retryWithBackoff(fn, options) {
  var maxRetries = options.maxRetries || 3;
  var baseDelay = options.baseDelay || 1000;
  var maxDelay = options.maxDelay || 10000;
  var shouldRetry = options.shouldRetry || function() { return true; };
  var onRetry = options.onRetry || function() {};

  function attempt(retryCount) {
    return fn().catch(function(err) {
      if (retryCount >= maxRetries || !shouldRetry(err)) {
        throw err;
      }

      var delay = calculateBackoff(retryCount, baseDelay, maxDelay);
      onRetry(err, retryCount + 1, delay);

      return wait(delay).then(function() {
        return attempt(retryCount + 1);
      });
    });
  }

  return attempt(0);
}

module.exports = { retryWithBackoff, calculateBackoff };

The backoff schedule with a 1-second base delay:

  • Attempt 1: immediate
  • Attempt 2: 1000ms + jitter (1.0 to 1.5 seconds)
  • Attempt 3: 2000ms + jitter (2.0 to 2.5 seconds)
  • Attempt 4: 4000ms + jitter (4.0 to 4.5 seconds)

Three retries with exponential backoff means a total wait of roughly 7 to 8 seconds. That is enough time for a temporary network issue to resolve without making the user wait an unreasonable amount of time.

Safe Retry Pattern for Transaction Verification

Verification is the most common endpoint you need to retry. If your verify call times out, you must know whether the payment succeeded. Here is a complete pattern:

// lib/paystack-verify.js
var retryWithBackoff = require('./retry').retryWithBackoff;

function verifyTransaction(reference) {
  return retryWithBackoff(
    function() {
      var controller = new AbortController();
      var timeoutId = setTimeout(function() {
        controller.abort();
      }, 15000);

      return fetch(
        'https://api.paystack.co/transaction/verify/' + reference,
        {
          headers: {
            'Authorization': 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
          },
          signal: controller.signal,
        }
      )
      .then(function(res) {
        clearTimeout(timeoutId);
        if (!res.ok && res.status !== 400) {
          if (res.status >= 500 || res.status === 429) {
            var error = new Error('Paystack returned ' + res.status);
            error.retryable = true;
            throw error;
          }
        }
        return res.json();
      })
      .catch(function(err) {
        clearTimeout(timeoutId);
        if (err.name === 'AbortError') {
          var timeoutError = new Error('Verify timeout for ' + reference);
          timeoutError.retryable = true;
          throw timeoutError;
        }
        if (err.code === 'ECONNRESET' || err.code === 'ENOTFOUND') {
          err.retryable = true;
        }
        throw err;
      });
    },
    {
      maxRetries: 3,
      baseDelay: 1000,
      shouldRetry: function(err) { return err.retryable === true; },
      onRetry: function(err, attempt, delay) {
        console.log(
          'Retry verify ' + reference +
          ' attempt ' + attempt +
          ' after ' + delay + 'ms: ' + err.message
        );
      },
    }
  );
}

module.exports = { verifyTransaction };

This pattern retries on timeouts, 5xx server errors, and 429 rate limits. It does not retry on 4xx client errors (bad reference, invalid key) because those will fail every time.

Safe Retry Pattern for Initialize (Generate Reference First)

You can retry /transaction/initialize safely, but only if you generate the reference before the first call. Paystack rejects duplicate references, so the second call will either succeed (if the first never reached Paystack) or return a duplicate reference error (if the first did reach Paystack and created the transaction).

// lib/paystack-initialize.js
var crypto = require('crypto');

function generateReference() {
  return 'txn_' + Date.now() + '_' + crypto.randomBytes(6).toString('hex');
}

function initializeWithRetry(email, amount, metadata) {
  // Generate reference BEFORE the first attempt
  var reference = generateReference();

  return retryWithBackoff(
    function() {
      var controller = new AbortController();
      var timeoutId = setTimeout(function() {
        controller.abort();
      }, 30000);

      return fetch('https://api.paystack.co/transaction/initialize', {
        method: 'POST',
        headers: {
          'Authorization': 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          email: email,
          amount: amount,
          reference: reference,
          metadata: metadata,
        }),
        signal: controller.signal,
      })
      .then(function(res) {
        clearTimeout(timeoutId);
        return res.json();
      })
      .then(function(data) {
        if (!data.status && data.message &&
            data.message.toLowerCase().includes('duplicate')) {
          console.log('Duplicate reference ' + reference + ', first request succeeded');
          return verifyAndReturnUrl(reference);
        }
        return data;
      })
      .catch(function(err) {
        clearTimeout(timeoutId);
        if (err.name === 'AbortError') {
          var timeoutError = new Error('Initialize timeout');
          timeoutError.retryable = true;
          throw timeoutError;
        }
        throw err;
      });
    },
    {
      maxRetries: 2,
      baseDelay: 2000,
      shouldRetry: function(err) { return err.retryable === true; },
      onRetry: function(err, attempt) {
        console.log('Retry initialize ' + reference + ' attempt ' + attempt);
      },
    }
  );
}

function verifyAndReturnUrl(reference) {
  return fetch(
    'https://api.paystack.co/transaction/verify/' + reference,
    {
      headers: {
        'Authorization': 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      },
    }
  )
  .then(function(res) { return res.json(); })
  .then(function(data) {
    return {
      status: true,
      data: {
        authorization_url: data.data.authorization_url,
        reference: reference,
      },
    };
  });
}

The key insight: by generating the reference first and sending the same reference on retry, you turn a non-idempotent endpoint into a safe one. If the first request succeeded, the duplicate detection catches it. If the first request never reached Paystack, the retry works normally.

Never Retry Charge Authorization Without Verification

The /transaction/charge_authorization endpoint charges a saved card. If this call times out, the charge may have gone through. Retrying blindly double-charges the customer.

The safe pattern:

  1. Generate a reference before calling charge_authorization
  2. If the call times out, verify the reference first
  3. Only retry the charge if verification confirms the transaction does not exist
// lib/safe-charge.js

function chargeWithSafeRetry(authorizationCode, email, amount) {
  var reference = generateReference();

  return attemptCharge(authorizationCode, email, amount, reference)
    .catch(function(err) {
      if (err.code !== 'PAYSTACK_TIMEOUT') {
        throw err;
      }

      console.log('Charge timed out for ' + reference + ', verifying...');

      return fetch(
        'https://api.paystack.co/transaction/verify/' + reference,
        {
          headers: {
            'Authorization': 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
          },
        }
      )
      .then(function(res) { return res.json(); })
      .then(function(verifyResult) {
        if (verifyResult.data && verifyResult.data.status === 'success') {
          console.log('Charge ' + reference + ' succeeded (post-timeout verify)');
          return verifyResult;
        }

        if (verifyResult.data && verifyResult.data.status === 'failed') {
          console.log('Charge ' + reference + ' failed, retrying...');
          var newReference = generateReference();
          return attemptCharge(authorizationCode, email, amount, newReference);
        }

        console.log('Charge ' + reference + ' status unclear, waiting...');
        return waitAndVerify(reference, 3);
      });
    });
}

function waitAndVerify(reference, attemptsLeft) {
  if (attemptsLeft <= 0) {
    var err = new Error(
      'Could not determine charge status for ' + reference
    );
    err.reference = reference;
    err.action = 'manual_check';
    throw err;
  }

  return wait(5000).then(function() {
    return fetch(
      'https://api.paystack.co/transaction/verify/' + reference,
      {
        headers: {
          'Authorization': 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
        },
      }
    )
    .then(function(res) { return res.json(); })
    .then(function(result) {
      if (result.data && result.data.status === 'success') {
        return result;
      }
      if (result.data && result.data.status === 'failed') {
        return result;
      }
      return waitAndVerify(reference, attemptsLeft - 1);
    });
  });
}

This pattern is more complex than simple retry, but it prevents the worst outcome in payment processing: charging a customer twice. The extra complexity is worth it.

Logging Timeouts for Monitoring

Log every timeout with enough context to diagnose patterns. Good timeout logs tell you which endpoint is slow, when it happens, and how many retries it took.

// lib/timeout-logger.js

function logTimeout(endpoint, reference, attempt, timeout, startTime) {
  var elapsed = Date.now() - startTime;

  var logEntry = {
    event: 'paystack_timeout',
    endpoint: endpoint,
    reference: reference || null,
    attempt: attempt,
    timeout_ms: timeout,
    elapsed_ms: elapsed,
    timestamp: new Date().toISOString(),
  };

  console.error(JSON.stringify(logEntry));
}

// Alert if timeouts exceed a threshold
var timeoutCounts = {};
var ALERT_THRESHOLD = 5;
var WINDOW_MS = 60000;

function trackTimeoutRate(endpoint) {
  var now = Date.now();
  if (!timeoutCounts[endpoint]) {
    timeoutCounts[endpoint] = [];
  }

  timeoutCounts[endpoint].push(now);

  timeoutCounts[endpoint] = timeoutCounts[endpoint].filter(function(t) {
    return t > now - WINDOW_MS;
  });

  if (timeoutCounts[endpoint].length >= ALERT_THRESHOLD) {
    console.error(
      'ALERT: ' + timeoutCounts[endpoint].length +
      ' Paystack timeouts on ' + endpoint +
      ' in the last ' + (WINDOW_MS / 1000) + ' seconds'
    );
  }
}

When you see a pattern of timeouts on a specific endpoint, check three things in order: your client timeout setting, status.paystack.com for known incidents, and your server's network connectivity (DNS, firewalls, proxy timeouts).

Verification: Test Your Timeout and Retry Logic

You cannot easily make Paystack time out on demand, but you can test your timeout and retry code by simulating slow responses.

Step 1: Test AbortController timeout.

Create a test server that delays responses, then point your Paystack client at it:

// test/slow-server.js
var http = require('http');

http.createServer(function(req, res) {
  // Simulate a 20-second response delay
  setTimeout(function() {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ status: true, data: {} }));
  }, 20000);
}).listen(3999);

// Your 15-second timeout should trigger before the 20-second delay

Step 2: Verify retry count and backoff timing.

Add timing logs to your retry function and verify the delays match your backoff schedule. First retry after about 1 second, second after about 2 seconds, third after about 4 seconds.

Step 3: Test duplicate reference handling.

In Paystack test mode, call /transaction/initialize twice with the same reference. The second call should return a "Duplicate Transaction Reference" error. Verify your retry handler catches this and verifies instead of failing.

Step 4: Verify your logs.

After triggering a timeout in test mode, check that your structured log includes the endpoint, reference, attempt number, timeout value, and elapsed time. This data is what you need when debugging production timeout issues at 3am.

Step 5: Test the charge safety pattern.

Use Paystack test mode to verify the charge-then-verify flow. Initialize a transaction, complete it, then call your chargeWithSafeRetry function with the same authorization code. Simulate a timeout on the charge call and verify that your code checks verification before retrying.

Key Takeaways

  • Paystack API timeouts are client-side. Your HTTP client gave up before Paystack responded. The server may have processed your request successfully.
  • Set your client timeout to 15-30 seconds. Many HTTP libraries default to 5 seconds, which is too short for payment APIs that involve bank communication.
  • Use AbortController in Node.js to enforce timeouts on fetch calls. The native fetch API does not have a built-in timeout option.
  • Only retry idempotent endpoints. Verify, list, and fetch endpoints are safe to retry. Initialize and charge endpoints are not, because the first request may have succeeded.
  • Never blindly retry /transaction/initialize. If the first request created a transaction but your client timed out, retrying creates a second transaction with a different reference.
  • Use exponential backoff with jitter for retries. Wait 1s, 2s, 4s between attempts, plus a random 0-500ms to avoid thundering herd when multiple clients retry simultaneously.
  • Log every timeout with the endpoint, reference, and attempt number. This data helps you tune your timeout values and identify systemic issues.

Frequently Asked Questions

What timeout value should I use for Paystack API calls?
Use 15 seconds for read endpoints (verify, list, fetch) and 30 seconds for write endpoints (initialize, charge, transfer). These values account for bank communication delays. Do not use the default 5-second timeout that many HTTP libraries set.
Is it safe to retry a Paystack API call that timed out?
It depends on the endpoint. GET requests (verify, list, fetch) are always safe to retry. POST requests that create transactions or charge cards are NOT safe to retry blindly. For initialize, use the same reference so duplicate detection catches double-creation. For charge_authorization, verify the reference first before retrying.
What is exponential backoff and why should I use it?
Exponential backoff means waiting longer between each retry attempt: 1 second, then 2 seconds, then 4 seconds. Add jitter (random delay) to prevent all clients from retrying at the same instant. This prevents overwhelming Paystack when many clients experience timeouts simultaneously, which would make the problem worse.
My Paystack API calls time out in production but work fine locally. Why?
Check three things: your production server DNS configuration (slow DNS eats into your timeout budget), proxy or load balancer timeout settings (Nginx, AWS ALB, or API Gateway may have shorter timeouts than your Node.js client), and network connectivity between your server and Paystack (some hosting providers throttle outbound HTTPS).
Should I use AbortController or axios timeout?
Both work. AbortController is the standard approach for the native fetch API in Node.js 18 and above. If you use axios, its built-in timeout option works fine. The important thing is that you set a timeout at all. Many developers use the default (no timeout or very short timeout) and never change it.

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