Bonaventure OgetoBy Bonaventure Ogeto|

When Paystack Is Down: Building Graceful Degradation

Build your payment integration to detect Paystack outages, inform customers honestly, queue orders for later processing, and optionally fail over to an alternative payment provider. Start with a health check that pings the Paystack API periodically. When it detects downtime, switch your checkout to a degraded mode: hide payment, show a "we are experiencing payment issues" message, or offer an alternative provider like M-Pesa Daraja for Kenyan customers. Queue orders so customers do not lose their cart. When Paystack recovers, process the queue.

Why Payment Provider Outages Happen and How Often

Paystack processes millions of transactions across Nigeria, Ghana, Kenya, South Africa, and other markets. Their infrastructure is solid, but no system has 100% uptime. Even 99.9% uptime means about 8.7 hours of downtime per year. That downtime will not be evenly distributed; it will come in chunks during the worst possible moments.

Common causes of Paystack downtime:

  • Upstream banking system issues (NIP downtime in Nigeria, bank API failures)
  • Infrastructure maintenance (database upgrades, server migrations)
  • Traffic spikes exceeding capacity (Black Friday, year-end sales)
  • DDoS attacks or security incidents
  • Third-party dependency failures (cloud provider issues, DNS problems)

Most Paystack outages are partial. Maybe card payments work but bank transfer is down. Maybe the API responds but with high latency. Maybe initialization works but verification is failing. Your degradation strategy should handle partial failures, not just total outages.

Building a Paystack Health Check

The first step is knowing that Paystack is down before your customers tell you. Build a health check that pings the Paystack API at regular intervals and tracks the response.

const axios = require('axios');
const EventEmitter = require('events');

class PaystackHealthCheck extends EventEmitter {
  constructor(secretKey, options = {}) {
    super();
    this.secretKey = secretKey;
    this.interval = options.interval || 30000;  // Check every 30 seconds
    this.timeout = options.timeout || 5000;      // 5 second timeout
    this.failThreshold = options.failThreshold || 3; // 3 failures before declaring outage
    this.consecutiveFailures = 0;
    this.isHealthy = true;
    this.lastChecked = null;
    this.lastLatency = null;
  }

  start() {
    this.check(); // Check immediately
    this.timer = setInterval(() => this.check(), this.interval);
    console.log(`Paystack health check started (every ${this.interval / 1000}s)`);
  }

  stop() {
    if (this.timer) clearInterval(this.timer);
  }

  async check() {
    const start = Date.now();

    try {
      // Use a lightweight endpoint that confirms the API is responding
      const response = await axios.get('https://api.paystack.co/bank', {
        headers: {
          Authorization: `Bearer ${this.secretKey}`,
        },
        timeout: this.timeout,
      });

      this.lastLatency = Date.now() - start;
      this.lastChecked = new Date();
      this.consecutiveFailures = 0;

      if (!this.isHealthy) {
        this.isHealthy = true;
        this.emit('recovered', { latency: this.lastLatency });
        console.log(`Paystack recovered. Latency: ${this.lastLatency}ms`);
      }

      // Warn if latency is high (over 2 seconds)
      if (this.lastLatency > 2000) {
        this.emit('degraded', { latency: this.lastLatency });
      }
    } catch (error) {
      this.lastLatency = Date.now() - start;
      this.lastChecked = new Date();
      this.consecutiveFailures++;

      console.warn(
        `Paystack health check failed (${this.consecutiveFailures}/${this.failThreshold}): ` +
        (error.code || error.message)
      );

      if (this.consecutiveFailures >= this.failThreshold && this.isHealthy) {
        this.isHealthy = false;
        this.emit('down', {
          consecutiveFailures: this.consecutiveFailures,
          error: error.message,
        });
        console.error('Paystack declared DOWN after', this.consecutiveFailures, 'consecutive failures');
      }
    }
  }

  getStatus() {
    return {
      healthy: this.isHealthy,
      lastChecked: this.lastChecked,
      lastLatency: this.lastLatency,
      consecutiveFailures: this.consecutiveFailures,
    };
  }
}

// Usage
const healthCheck = new PaystackHealthCheck(process.env.PAYSTACK_SECRET_KEY);

healthCheck.on('down', (info) => {
  // Send alert to your team (Slack, email, PagerDuty)
  console.error('ALERT: Paystack is down.', info);
  // Enable degraded mode
});

healthCheck.on('recovered', (info) => {
  console.log('Paystack is back up.', info);
  // Disable degraded mode, process queued orders
});

healthCheck.on('degraded', (info) => {
  console.warn('Paystack is slow. Latency:', info.latency, 'ms');
});

healthCheck.start();

module.exports = healthCheck;

The health check uses a lightweight endpoint (/bank) to confirm the API is responding. It requires 3 consecutive failures before declaring an outage to avoid false positives from transient network hiccups. When the API recovers, it emits a "recovered" event so you can re-enable normal checkout.

Degradation Middleware for Express

Instead of checking Paystack health in every route handler, build a middleware that intercepts payment requests and routes them appropriately based on the current health status.

const healthCheck = require('./paystack-health-check');

function paystackDegradationMiddleware(req, res, next) {
  const status = healthCheck.getStatus();

  // Attach health status to the request for downstream use
  req.paystackHealthy = status.healthy;
  req.paystackLatency = status.lastLatency;

  // If Paystack is down and this is a payment endpoint, handle gracefully
  if (!status.healthy && isPaymentEndpoint(req.path)) {
    // Option 1: Return a structured error the frontend can handle
    return res.status(503).json({
      error: 'payment_provider_unavailable',
      message: 'Our payment system is temporarily unavailable. Please try again in a few minutes.',
      retryAfter: 60, // Suggest retry in 60 seconds
      alternatives: getAlternativePaymentMethods(),
    });
  }

  // If Paystack is degraded (high latency), set a longer timeout
  if (status.lastLatency > 2000) {
    req.paystackTimeout = 15000; // Give Paystack more time when it is slow
  }

  next();
}

function isPaymentEndpoint(path) {
  const paymentPaths = ['/api/pay', '/api/payment', '/api/checkout', '/api/subscribe'];
  return paymentPaths.some(p => path.startsWith(p));
}

function getAlternativePaymentMethods() {
  // Return available alternatives based on your setup
  return [
    {
      method: 'mpesa',
      name: 'M-Pesa',
      description: 'Pay directly via M-Pesa STK Push',
      available: true, // Check M-Pesa health separately
    },
    {
      method: 'bank_transfer',
      name: 'Direct Bank Transfer',
      description: 'Transfer to our bank account and upload proof',
      available: true, // Always available
    },
  ];
}

// Apply to your Express app
const app = require('express')();
app.use(paystackDegradationMiddleware);

This middleware does three things: it attaches health status to every request (so any route can check it), it blocks payment requests with a structured error when Paystack is down, and it adjusts timeouts when Paystack is slow. The frontend can read the error code and alternatives array to show the customer appropriate options.

Graceful UI During Outages

The frontend experience during an outage matters enormously. A good degraded experience keeps customers on your site. A bad one sends them to a competitor.

What NOT to do:

  • Show a generic "500 Internal Server Error" page
  • Show a loading spinner that never resolves
  • Show "Payment failed" with no explanation or next steps
  • Crash the entire page because the payment component threw an unhandled error

What to do instead:

// Frontend: handle payment provider unavailability
async function handleCheckout(orderData) {
  try {
    const response = await fetch('/api/pay', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(orderData),
    });

    if (response.status === 503) {
      const data = await response.json();

      if (data.error === 'payment_provider_unavailable') {
        showDegradedCheckout({
          message: data.message,
          alternatives: data.alternatives,
          retryAfter: data.retryAfter,
        });
        return;
      }
    }

    if (!response.ok) {
      throw new Error('Payment initialization failed');
    }

    const data = await response.json();
    // Normal payment flow
    redirectToPaystack(data.authorization_url);
  } catch (error) {
    showDegradedCheckout({
      message: 'We are having trouble connecting to our payment system. Your order is saved.',
      alternatives: [],
      retryAfter: 120,
    });
  }
}

function showDegradedCheckout({ message, alternatives, retryAfter }) {
  // Show a friendly message with alternatives
  const container = document.getElementById('checkout-container');
  container.innerHTML = `
    

Payment Temporarily Unavailable

${message}

Your cart is saved. You can:

    ${alternatives.map(alt => `
  • ${alt.description}
  • `).join('')}
`; }

The key principles: tell the customer what is happening (honestly), reassure them their order/cart is not lost, give them options, and make retrying easy. Never blame the customer for a provider outage.

Queuing Orders During Outages

If a customer wants to buy and your payment provider is down, you have a choice: turn them away, or accept the order and process payment when the provider recovers. For many businesses, the second option is better.

const express = require('express');
const app = express();
const healthCheck = require('./paystack-health-check');

// In-memory queue for demo. Use Redis, BullMQ, or a database in production.
const paymentQueue = [];

app.post('/api/pay', async (req, res) => {
  const { email, amount, orderId, items } = req.body;

  if (!healthCheck.getStatus().healthy) {
    // Queue the order for later payment processing
    const queueEntry = {
      id: 'q_' + Date.now(),
      email,
      amount,
      orderId,
      items,
      queuedAt: new Date().toISOString(),
      status: 'queued',
    };

    paymentQueue.push(queueEntry);

    // Save the order in your database with status "payment_pending"
    await db.query(
      'INSERT INTO orders (id, email, amount, status) VALUES ($1, $2, $3, $4)',
      [orderId, email, amount, 'payment_pending']
    );

    console.log('Order queued for later payment:', orderId);

    return res.status(202).json({
      message: 'Your order has been saved. We will send you a payment link as soon as our payment system is back online.',
      orderId: orderId,
      queuePosition: paymentQueue.length,
    });
  }

  // Normal payment flow when Paystack is healthy
  try {
    const response = await initializePaystackTransaction(email, amount);
    res.json({
      authorization_url: response.authorization_url,
      reference: response.reference,
    });
  } catch (error) {
    res.status(500).json({ error: 'Payment initialization failed' });
  }
});

// Process queued orders when Paystack recovers
healthCheck.on('recovered', async () => {
  console.log(`Processing ${paymentQueue.length} queued orders...`);

  while (paymentQueue.length > 0) {
    const entry = paymentQueue.shift();

    try {
      // Initialize payment for the queued order
      const response = await initializePaystackTransaction(entry.email, entry.amount);

      // Send the customer a payment link via email or SMS
      await sendPaymentLink(entry.email, response.authorization_url, entry.orderId);

      console.log(`Payment link sent for queued order ${entry.orderId}`);
    } catch (error) {
      console.error(`Failed to process queued order ${entry.orderId}:`, error.message);
      // Re-queue or flag for manual handling
      paymentQueue.push(entry);
      break; // Stop if Paystack is failing again
    }

    // Small delay between requests to avoid hammering Paystack right after recovery
    await new Promise(resolve => setTimeout(resolve, 1000));
  }
});

app.listen(3000);

This approach saves the customer's intent even when you cannot process payment. When Paystack recovers, you send them a payment link. This works well for e-commerce (customer gets the order after paying) but not for instant-delivery products (where payment must happen before delivery).

M-Pesa Daraja as a Kenyan Fallback

If your product serves Kenyan customers, you have an ace up your sleeve: M-Pesa Daraja API. M-Pesa infrastructure is completely independent of Paystack. When Paystack is down, M-Pesa is almost certainly still working (and vice versa).

This is not about replacing Paystack. It is about having a fallback that lets you keep processing payments during outages.

const axios = require('axios');

class MpesaFallback {
  constructor(config) {
    this.consumerKey = config.consumerKey;
    this.consumerSecret = config.consumerSecret;
    this.shortcode = config.shortcode;
    this.passkey = config.passkey;
    this.callbackUrl = config.callbackUrl;
    this.accessToken = null;
    this.tokenExpiry = null;
  }

  async getAccessToken() {
    if (this.accessToken && this.tokenExpiry > Date.now()) {
      return this.accessToken;
    }

    const auth = Buffer.from(
      `${this.consumerKey}:${this.consumerSecret}`
    ).toString('base64');

    const response = await axios.get(
      'https://api.safaricom.co.ke/oauth/v1/generate?grant_type=client_credentials',
      { headers: { Authorization: `Basic ${auth}` } }
    );

    this.accessToken = response.data.access_token;
    this.tokenExpiry = Date.now() + 3500 * 1000; // Expires in ~1 hour
    return this.accessToken;
  }

  async stkPush(phoneNumber, amountKES, accountReference) {
    const token = await this.getAccessToken();
    const timestamp = new Date()
      .toISOString()
      .replace(/[-:T]/g, '')
      .slice(0, 14);

    const password = Buffer.from(
      `${this.shortcode}${this.passkey}${timestamp}`
    ).toString('base64');

    const response = await axios.post(
      'https://api.safaricom.co.ke/mpesa/stkpush/v1/processrequest',
      {
        BusinessShortCode: this.shortcode,
        Password: password,
        Timestamp: timestamp,
        TransactionType: 'CustomerPayBillOnline',
        Amount: amountKES,
        PartyA: phoneNumber,
        PartyB: this.shortcode,
        PhoneNumber: phoneNumber,
        CallBackURL: this.callbackUrl,
        AccountReference: accountReference,
        TransactionDesc: 'Payment',
      },
      {
        headers: {
          Authorization: `Bearer ${token}`,
          'Content-Type': 'application/json',
        },
      }
    );

    return {
      success: response.data.ResponseCode === '0',
      checkoutRequestId: response.data.CheckoutRequestID,
      merchantRequestId: response.data.MerchantRequestID,
    };
  }
}

// Usage in your payment router
const mpesaFallback = new MpesaFallback({
  consumerKey: process.env.MPESA_CONSUMER_KEY,
  consumerSecret: process.env.MPESA_CONSUMER_SECRET,
  shortcode: process.env.MPESA_SHORTCODE,
  passkey: process.env.MPESA_PASSKEY,
  callbackUrl: process.env.MPESA_CALLBACK_URL,
});

// In your payment endpoint
async function processPayment(req, res) {
  const { email, amount, phone, orderId } = req.body;

  if (healthCheck.getStatus().healthy) {
    // Primary: Paystack
    return processPaystackPayment(email, amount, orderId, res);
  }

  if (phone && phone.startsWith('+254')) {
    // Fallback: M-Pesa for Kenyan customers
    try {
      const result = await mpesaFallback.stkPush(phone, amount / 100, orderId);
      return res.json({
        provider: 'mpesa',
        message: 'Check your phone for the M-Pesa prompt',
        checkoutRequestId: result.checkoutRequestId,
      });
    } catch (error) {
      console.error('M-Pesa fallback failed:', error.message);
    }
  }

  // No fallback available
  return res.status(503).json({
    error: 'payment_provider_unavailable',
    message: 'Payment is temporarily unavailable. Your order has been saved.',
  });
}

The M-Pesa fallback adds complexity (you now have two payment providers to maintain, two webhook handlers, two reconciliation processes), but for Kenyan products it is worth it. When Paystack is down, you can still process payments. When M-Pesa is down (rare, but it happens), Paystack covers you.

The Circuit Breaker Pattern for Payment APIs

The health check above is a basic version of the circuit breaker pattern. A proper circuit breaker has three states: closed (normal), open (outage detected), and half-open (testing recovery).

class PaystackCircuitBreaker {
  constructor(options = {}) {
    this.state = 'closed'; // closed, open, half-open
    this.failureCount = 0;
    this.successCount = 0;
    this.failureThreshold = options.failureThreshold || 5;
    this.recoveryTimeout = options.recoveryTimeout || 60000; // 1 minute
    this.halfOpenMaxAttempts = options.halfOpenMaxAttempts || 3;
    this.lastFailureTime = null;
  }

  async execute(apiCall) {
    if (this.state === 'open') {
      // Check if recovery timeout has passed
      if (Date.now() - this.lastFailureTime > this.recoveryTimeout) {
        this.state = 'half-open';
        this.successCount = 0;
        console.log('Circuit breaker: half-open, testing recovery...');
      } else {
        throw new Error('Circuit breaker is open. Paystack is unavailable.');
      }
    }

    try {
      const result = await apiCall();

      if (this.state === 'half-open') {
        this.successCount++;
        if (this.successCount >= this.halfOpenMaxAttempts) {
          this.state = 'closed';
          this.failureCount = 0;
          console.log('Circuit breaker: closed. Paystack recovered.');
        }
      }

      this.failureCount = 0;
      return result;
    } catch (error) {
      this.failureCount++;
      this.lastFailureTime = Date.now();

      if (this.state === 'half-open') {
        this.state = 'open';
        console.log('Circuit breaker: re-opened. Recovery attempt failed.');
      } else if (this.failureCount >= this.failureThreshold) {
        this.state = 'open';
        console.error(`Circuit breaker: opened after ${this.failureCount} failures.`);
      }

      throw error;
    }
  }

  getState() {
    return this.state;
  }
}

// Usage
const breaker = new PaystackCircuitBreaker({
  failureThreshold: 3,
  recoveryTimeout: 30000,
});

async function initializePayment(email, amount) {
  return breaker.execute(async () => {
    const response = await axios.post(
      'https://api.paystack.co/transaction/initialize',
      { email, amount },
      {
        headers: {
          Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
          'Content-Type': 'application/json',
        },
        timeout: 10000,
      }
    );
    return response.data;
  });
}

module.exports = { breaker, initializePayment };

The circuit breaker prevents your app from hammering a dead API. When Paystack is down, the circuit opens and all payment requests fail immediately with a clear error instead of waiting for a timeout. After the recovery timeout, the breaker enters half-open state and tests with a few requests. If those succeed, it closes and normal traffic resumes.

Monitoring and Alerting for Payment Outages

You should know about a Paystack outage within minutes, not when a customer complains. Set up monitoring that alerts your team immediately.

What to monitor:

  • Health check status (the health check class from earlier emits events you can hook into)
  • Payment success rate (a sudden drop in the success-to-failure ratio)
  • API response latency (latency spikes often precede full outages)
  • Webhook delivery rate (if webhooks stop arriving, something is wrong)
// Simple monitoring that sends alerts
const healthCheck = require('./paystack-health-check');

healthCheck.on('down', async (info) => {
  // Send to Slack
  await axios.post(process.env.SLACK_WEBHOOK_URL, {
    text: `Payment Alert: Paystack is DOWN. ${info.consecutiveFailures} consecutive failures. Error: ${info.error}. Degraded mode activated.`,
  });

  // Send to PagerDuty, email, SMS, etc.
});

healthCheck.on('recovered', async (info) => {
  await axios.post(process.env.SLACK_WEBHOOK_URL, {
    text: `Payment Recovery: Paystack is back UP. Latency: ${info.latency}ms. Processing queued orders.`,
  });
});

healthCheck.on('degraded', async (info) => {
  // Only alert if latency is significantly high
  if (info.latency > 5000) {
    await axios.post(process.env.SLACK_WEBHOOK_URL, {
      text: `Payment Warning: Paystack latency is ${info.latency}ms (threshold: 5000ms). Payments may be slow.`,
    });
  }
});

Also subscribe to the Paystack status page (status.paystack.com) for email or SMS notifications. This gives you Paystack's own view of their system health, which complements your external monitoring.

How to Verify Your Degradation Strategy Works

You cannot wait for a real outage to test your degradation code. Simulate outages in your staging environment.

  1. Simulate a full outage. Point your staging Paystack URL to a non-existent endpoint or block it at the network level. Confirm your health check detects the outage, the middleware returns 503 with alternatives, and the frontend shows the degraded checkout.
  2. Simulate high latency. Use a tool like tc (traffic control on Linux) or a proxy like Toxiproxy to add 5+ seconds of latency to Paystack API calls. Confirm your circuit breaker adjusts timeouts and your monitoring alerts fire.
  3. Test order queuing. With Paystack simulated as down, place several orders. Confirm they are queued. Then restore Paystack connectivity and confirm the queue processes and payment links are sent.
  4. Test the M-Pesa fallback. With Paystack simulated as down, submit a payment for a Kenyan customer. Confirm the M-Pesa STK Push fires and the payment processes through the M-Pesa callback handler.
  5. Test recovery. Simulate an outage, queue some orders, then restore connectivity. Confirm the circuit breaker moves from open to half-open to closed, queued orders are processed, and normal checkout resumes.

Run these simulations quarterly, or at least before major sales events (Black Friday, product launches) when the stakes are highest. Your degradation code is like an insurance policy. You hope you never need it, but when you do, it has to work.

Key Takeaways

  • Every payment provider experiences downtime. Paystack is no exception. Build your integration assuming occasional outages.
  • A health check that pings the Paystack API every 30 to 60 seconds lets you detect outages before customers report them.
  • Graceful degradation means showing customers a useful message and offering alternatives, not a blank error page or a spinning loader that never resolves.
  • Queue orders during outages so customers do not lose their shopping cart. Process the queue when the provider recovers.
  • For Kenyan products, M-Pesa Daraja API can serve as a fallback payment channel that is completely independent of Paystack infrastructure.
  • Middleware-based degradation is cleaner than scattering outage checks throughout your codebase. One middleware, one circuit breaker, consistent behavior everywhere.

Frequently Asked Questions

How do I know if Paystack is down or if the problem is on my side?
Check status.paystack.com for any reported incidents. If Paystack reports all systems operational but your API calls are failing, the problem is likely on your side (network, firewall, expired keys, etc.). If your health check is failing but status.paystack.com shows no issues, try making a Paystack API call from a different network or machine to rule out local network problems.
Should I build failover to another payment provider like Flutterwave?
It depends on your scale and risk tolerance. For most apps, graceful degradation (queue orders, send payment links later) is sufficient. Multi-provider failover adds significant complexity because you need to maintain two integrations, two webhook handlers, and reconcile transactions across both providers. It makes sense for high-volume businesses where every minute of downtime means significant revenue loss.
Can I use the Paystack status page API to check for outages?
Paystack uses Atlassian Statuspage. You can subscribe to updates via email, SMS, or RSS. There is also a JSON API at status.paystack.com/api/v2/status.json that returns the current status. However, your own health check will detect issues faster because status pages are often updated manually with a delay.
How long do Paystack outages typically last?
Most Paystack incidents are resolved within 30 minutes to 2 hours. Partial outages (one payment channel down) are more common than total outages and usually resolve faster. Major outages affecting all services are rare but can last several hours. Check their status page history for past incident durations.
What should I tell my customers during a payment outage?
Be honest and specific. "Our payment system is temporarily unavailable. Your order has been saved and we will send you a payment link as soon as the issue is resolved." Avoid blaming Paystack by name in customer-facing messages. Focus on what the customer can do (wait, try an alternative method, contact support).

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