Bonaventure OgetoBy Bonaventure Ogeto|

Building an M-Pesa Fallback When Paystack Is Unavailable

To build an M-Pesa fallback, implement a circuit breaker that monitors Paystack health and automatically routes M-Pesa charges to direct Daraja STK Push when Paystack is unavailable. Your switching middleware checks Paystack status before each charge, falls back to Daraja on failure, and records the provider used so your reconciliation logic can pull from both sources.

Why You Need a Fallback

If you run a business in Kenya where M-Pesa is your primary payment method, your checkout flow has a dependency chain: your app talks to Paystack, Paystack talks to Safaricom. If any link in that chain breaks, your customers cannot pay.

Paystack is reliable. But "reliable" is not "perfect." Every payment provider has maintenance windows, degraded performance periods, and the occasional outage. Safaricom's own infrastructure has its moments too. And the connection between Paystack and Safaricom adds another potential failure point.

The question is not whether this will happen. The question is what your application does when it happens.

For a shop doing a few transactions a day, you can afford to tell customers to try again later. For a ticketing platform during a concert sale, a matatu fare system, or a SaaS billing run processing hundreds of charges in a window, even 30 minutes of downtime means real revenue loss and angry customers.

The solution is a fallback: if Paystack cannot process the M-Pesa charge, route it directly through Safaricom's Daraja API instead. You already know M-Pesa works because your customers use it every day outside of your app. The goal is to reach Safaricom through a different path when the Paystack path is blocked.

This is not theoretical. Several Kenyan fintech teams run exactly this architecture. It adds complexity, but the trade-off is worth it when payment availability is a business requirement.

What the Fallback Covers and What It Does Not

Before you start building, you need to understand the boundaries of this approach.

What the fallback covers:

  • M-Pesa STK Push. If Paystack is down and the customer wants to pay via M-Pesa, you can trigger the STK push directly through Daraja. The customer experience is nearly identical: they get a prompt on their phone, enter their PIN, and the payment goes through.
  • M-Pesa C2B. If you use Paystack for paybill/till collection, you can fall back to your own C2B registration on Daraja.

What the fallback does NOT cover:

  • Card payments. You cannot process Visa/Mastercard through Daraja. If Paystack is down and a customer wants to pay by card, your only option is a different card payment provider (Flutterwave, IntaSend) or asking the customer to use M-Pesa instead.
  • Pesalink. Daraja does not handle bank transfers. No fallback path here without another gateway.
  • Airtel Money. Daraja is Safaricom's API. It does not process Airtel Money. You would need Airtel's own API as a separate fallback.
  • Paystack Checkout UI. The hosted checkout page is a Paystack product. When Paystack is down, it is gone. Your fallback needs its own UI to collect the phone number and trigger the STK push.

For most Kenyan products, M-Pesa accounts for the majority of transactions. A fallback that covers M-Pesa alone still protects the bulk of your revenue.

Prerequisites: What You Need on the Daraja Side

The fallback only works if you have a working Daraja integration ready to go. You cannot set this up during an outage. It needs to be built, tested, and deployed before you need it.

Here is what you need:

  1. A Safaricom Developer Account. Register at developer.safaricom.co.ke if you have not already.
  2. A paybill or till number. You need your own Safaricom business shortcode. This is the number that appears on the customer's phone during the STK push. Apply through Safaricom Business.
  3. Daraja API credentials. Consumer Key, Consumer Secret, and the passkey for your shortcode. You will use these to generate OAuth tokens and initiate STK Push requests.
  4. A callback URL. Daraja sends payment results to a callback URL you provide. This must be a publicly accessible HTTPS endpoint. It is separate from your Paystack webhook URL.
  5. A tested STK Push flow. Test this end to end on the Daraja sandbox first, then with live credentials on small amounts. Do not wait until Paystack is down to discover your Daraja integration has a bug.

If you do not have Daraja credentials yet, the application process takes time. Safaricom's go-live approval for API access is not instant. Plan for this. Get approved well before you need the fallback.

For the full Daraja setup walkthrough, see the M-Pesa integration hub.

Health Check Patterns for Paystack

The first question your switching logic needs to answer: is Paystack up right now?

You have two approaches to answering this question, and a good system uses both.

Proactive health checks (polling). Run a lightweight check against Paystack on a schedule. Every 30 seconds or every minute, ping a Paystack endpoint and record whether it responded successfully within a reasonable timeout.

// healthCheck.js
const PAYSTACK_HEALTH_URL = 'https://api.paystack.co/bank?currency=KES';
const HEALTH_CHECK_TIMEOUT = 5000; // 5 seconds

let paystackHealthy = true;
let lastCheckTime = null;

async function checkPaystackHealth() {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT);

  try {
    const res = await fetch(PAYSTACK_HEALTH_URL, {
      headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` },
      signal: controller.signal,
    });
    clearTimeout(timeout);

    paystackHealthy = res.ok;
    lastCheckTime = new Date();
  } catch (err) {
    clearTimeout(timeout);
    paystackHealthy = false;
    lastCheckTime = new Date();
    console.error('Paystack health check failed:', err.message);
  }
}

// Run every 30 seconds
setInterval(checkPaystackHealth, 30000);
checkPaystackHealth(); // initial check on startup

function isPaystackHealthy() {
  return paystackHealthy;
}

module.exports = { isPaystackHealthy, checkPaystackHealth };

A few design notes on this approach:

  • Use a cheap, read-only endpoint like /bank or /bank?currency=KES. It returns a list of banks and costs nothing. Do not use the charge endpoint for health checks.
  • Set a strict timeout. If Paystack takes more than 5 seconds to respond to a simple list endpoint, something is wrong.
  • Store the result in memory. The health check runs in the background. Your payment code reads the cached result, not blocking on a health check during checkout.

Reactive detection (on failure). Even with polling, you might catch an issue first when an actual charge fails. If a Paystack charge request times out or returns a 500-series error, that is also health signal. Feed it into your circuit breaker state (covered in the next section).

Combining both approaches means you catch most outages within 30 seconds proactively, and you catch the rest reactively when the first affected charge fails.

Circuit Breaker Implementation

A circuit breaker is a pattern borrowed from electrical engineering. When too many failures occur, the circuit "opens" and stops sending traffic to the failing service. After a cooldown period, it lets a test request through. If the test succeeds, the circuit "closes" and normal traffic resumes.

Applied to payments: when Paystack fails repeatedly, stop trying Paystack and route all M-Pesa charges to Daraja. Periodically test whether Paystack has recovered. When it has, switch back.

// circuitBreaker.js

const STATE = {
  CLOSED: 'CLOSED',       // Paystack is working, use it normally
  OPEN: 'OPEN',           // Paystack is down, use Daraja
  HALF_OPEN: 'HALF_OPEN', // Testing if Paystack has recovered
};

class PaystackCircuitBreaker {
  constructor(options = {}) {
    this.state = STATE.CLOSED;
    this.failureCount = 0;
    this.failureThreshold = options.failureThreshold || 3;
    this.recoveryTimeout = options.recoveryTimeout || 60000; // 1 minute
    this.lastFailureTime = null;
  }

  isOpen() {
    if (this.state === STATE.OPEN) {
      // Check if enough time has passed to try again
      const now = Date.now();
      if (now - this.lastFailureTime >= this.recoveryTimeout) {
        this.state = STATE.HALF_OPEN;
        console.log('Circuit breaker: HALF_OPEN, testing Paystack');
        return false; // Allow one test request through
      }
      return true; // Still in cooldown
    }
    return false;
  }

  recordSuccess() {
    this.failureCount = 0;
    if (this.state === STATE.HALF_OPEN) {
      this.state = STATE.CLOSED;
      console.log('Circuit breaker: CLOSED, Paystack recovered');
    }
  }

  recordFailure() {
    this.failureCount++;
    this.lastFailureTime = Date.now();

    if (this.failureCount >= this.failureThreshold) {
      this.state = STATE.OPEN;
      console.log(`Circuit breaker: OPEN after ${this.failureCount} failures`);
    }
  }

  getState() {
    return this.state;
  }
}

module.exports = { PaystackCircuitBreaker, STATE };

The three states work like this:

  1. CLOSED (normal). Paystack is working. All charges go to Paystack. If a charge fails, increment the failure counter. After three consecutive failures, open the circuit.
  2. OPEN (fallback active). Paystack is down. All M-Pesa charges go to Daraja. The circuit stays open for a recovery timeout period (one minute in this example). After the timeout, move to HALF_OPEN.
  3. HALF_OPEN (testing). Send one test charge to Paystack. If it succeeds, close the circuit and resume normal operation. If it fails, reopen the circuit and wait another recovery period.

The threshold and timeout values depend on your business. Three failures and a one-minute recovery window works for most cases. If you process thousands of transactions per minute, you might set the threshold lower and the recovery window shorter. If you process a few per hour, you might set the threshold higher to avoid flapping on transient errors.

Switching Middleware: The Payment Router

Now bring the health check and circuit breaker together into middleware that decides where to send each M-Pesa charge.

// paymentRouter.js
const { PaystackCircuitBreaker } = require('./circuitBreaker');
const { isPaystackHealthy } = require('./healthCheck');
const { chargeViaPaystack } = require('./providers/paystack');
const { chargeViaDaraja } = require('./providers/daraja');

const breaker = new PaystackCircuitBreaker({
  failureThreshold: 3,
  recoveryTimeout: 60000,
});

async function chargeMpesa({ phone, amount, reference, email }) {
  const provider = selectProvider();

  if (provider === 'paystack') {
    return attemptPaystack({ phone, amount, reference, email });
  } else {
    return attemptDaraja({ phone, amount, reference });
  }
}

function selectProvider() {
  // If the circuit breaker is open, use Daraja
  if (breaker.isOpen()) {
    return 'daraja';
  }

  // If proactive health check says Paystack is down, use Daraja
  if (!isPaystackHealthy()) {
    return 'daraja';
  }

  return 'paystack';
}

async function attemptPaystack({ phone, amount, reference, email }) {
  try {
    const result = await chargeViaPaystack({ phone, amount, reference, email });
    breaker.recordSuccess();
    return { provider: 'paystack', ...result };
  } catch (err) {
    breaker.recordFailure();
    console.error('Paystack charge failed, falling back to Daraja:', err.message);

    // Fall back to Daraja for this specific request
    const result = await attemptDaraja({ phone, amount, reference });
    return result;
  }
}

async function attemptDaraja({ phone, amount, reference }) {
  try {
    const result = await chargeViaDaraja({ phone, amount, reference });
    return { provider: 'daraja', ...result };
  } catch (err) {
    // Both providers failed. This is a real problem.
    console.error('Daraja charge also failed:', err.message);
    throw new Error('All payment providers unavailable');
  }
}

module.exports = { chargeMpesa };

The key design decisions here:

  • The router checks two signals. The proactive health check and the circuit breaker. Either one can trigger the fallback.
  • Paystack failures trigger an immediate fallback for that request. The customer does not wait for you to detect the outage. If their specific charge fails on Paystack, it retries on Daraja right away.
  • Every result includes the provider name. This is critical for reconciliation. When you save the payment record in your database, you must know which provider processed it.
  • If both providers fail, throw. Do not silently swallow double failures. Surface the error so your checkout UI can show a meaningful message.

In your Express route, the usage looks like this:

const { chargeMpesa } = require('./paymentRouter');

app.post('/api/charge', async (req, res) => {
  const { phone, amount, email } = req.body;
  const reference = `PAY_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;

  try {
    const result = await chargeMpesa({ phone, amount, reference, email });

    // Save to database with provider information
    await db.payments.create({
      reference,
      phone,
      amount,
      provider: result.provider,          // 'paystack' or 'daraja'
      providerReference: result.providerRef,
      status: 'pending',
      createdAt: new Date(),
    });

    res.json({ success: true, reference, provider: result.provider });
  } catch (err) {
    res.status(503).json({
      success: false,
      message: 'Payment service temporarily unavailable. Please try again.',
    });
  }
});

Database Schema for Multi-Provider Payments

When payments can come from two different providers, your database schema needs to accommodate both. The simplest approach is a single payments table with a provider column.

CREATE TABLE payments (
  id            SERIAL PRIMARY KEY,
  reference     VARCHAR(100) UNIQUE NOT NULL,  -- Your internal reference
  provider      VARCHAR(20) NOT NULL,           -- 'paystack' or 'daraja'
  provider_ref  VARCHAR(100),                   -- Paystack reference or Daraja CheckoutRequestID
  phone         VARCHAR(20) NOT NULL,
  amount        INTEGER NOT NULL,               -- Amount in cents
  currency      VARCHAR(3) DEFAULT 'KES',
  status        VARCHAR(20) DEFAULT 'pending',  -- pending, success, failed, timeout
  provider_meta JSONB,                          -- Raw provider response for debugging
  created_at    TIMESTAMPTZ DEFAULT NOW(),
  confirmed_at  TIMESTAMPTZ,
  settled_at    TIMESTAMPTZ
);

CREATE INDEX idx_payments_reference ON payments(reference);
CREATE INDEX idx_payments_provider ON payments(provider);
CREATE INDEX idx_payments_status ON payments(status);
CREATE INDEX idx_payments_provider_ref ON payments(provider_ref);

The important columns:

  • provider. Which system processed this payment. Every query, report, and reconciliation job will filter or group by this column.
  • provider_ref. Paystack gives you a transaction reference. Daraja gives you a CheckoutRequestID. Store whichever one applies. You will need it when querying the provider for status updates.
  • provider_meta. A JSONB column for the raw response from the provider. The formats are different between Paystack and Daraja. Storing the raw data lets you debug issues without going back to provider logs.
  • settled_at. Paystack settlements and Daraja M-Pesa settlements arrive at different times through different channels. Track when each payment was actually settled.

Your application code should never assume the provider. Always check the provider column before interpreting the provider_ref or provider_meta fields.

Handling Webhooks from Both Providers

When payments flow through two providers, you need two webhook endpoints. The Paystack webhook and the Daraja callback have completely different formats, authentication mechanisms, and delivery characteristics.

// Paystack webhook (you probably already have this)
app.post('/webhooks/paystack', express.json(), (req, res) => {
  const hash = crypto
    .createHmac('sha512', process.env.PAYSTACK_SECRET_KEY)
    .update(JSON.stringify(req.body))
    .digest('hex');

  if (hash !== req.headers['x-paystack-signature']) {
    return res.status(401).send('Invalid signature');
  }

  if (req.body.event === 'charge.success') {
    const { reference, amount } = req.body.data;
    confirmPayment(reference, 'paystack', req.body.data);
  }

  res.status(200).send('OK');
});

// Daraja STK Push callback (separate endpoint)
app.post('/webhooks/daraja/stk', express.json(), (req, res) => {
  // Daraja callbacks do not have HMAC signatures
  // Validate by checking the request structure and your CheckoutRequestID
  const callback = req.body?.Body?.stkCallback;

  if (!callback) {
    return res.status(400).send('Invalid callback format');
  }

  const checkoutRequestId = callback.CheckoutRequestID;
  const resultCode = callback.ResultCode;

  if (resultCode === 0) {
    // Payment successful
    const metadata = callback.CallbackMetadata?.Item || [];
    const amount = metadata.find(i => i.Name === 'Amount')?.Value;
    const mpesaReceipt = metadata.find(i => i.Name === 'MpesaReceiptNumber')?.Value;

    confirmPayment(checkoutRequestId, 'daraja', {
      amount,
      mpesaReceipt,
      raw: callback,
    });
  } else {
    // Payment failed or cancelled
    failPayment(checkoutRequestId, 'daraja', callback);
  }

  // Daraja expects a specific response format
  res.json({
    ResultCode: 0,
    ResultDesc: 'Accepted',
  });
});

async function confirmPayment(providerRef, provider, data) {
  await db.payments.update({
    where: { provider_ref: providerRef, provider },
    data: {
      status: 'success',
      confirmed_at: new Date(),
      provider_meta: data,
    },
  });
}

The differences you need to handle:

  • Authentication. Paystack uses HMAC-SHA512 signature verification. Daraja callbacks do not have a signature header. For Daraja, validate the callback by checking the CheckoutRequestID against your database and optionally restricting the callback URL to Safaricom's IP ranges.
  • Response format. Paystack wants a 200 OK. Daraja wants a JSON response with ResultCode: 0.
  • Retry behavior. Paystack retries failed webhook deliveries. Daraja's retry behavior is less predictable. Build idempotent handlers for both.
  • Reference format. Paystack uses your reference (the one you passed during initialization). Daraja uses the CheckoutRequestID it returned when you initiated the STK Push. Make sure you store the right identifier for each provider.

Reconciliation Across Providers

This is the hardest part of the dual-provider setup. You have money arriving through two completely different channels, and you need one source of truth.

Paystack reconciliation is relatively straightforward. Paystack settles to your bank account in batches. The Paystack dashboard shows you exactly which transactions are in each settlement. You can match settlement amounts against your database.

Daraja reconciliation is more manual. M-Pesa payments to your paybill appear on your M-Pesa statement. You download the statement (or pull it via the Account Balance and Transaction Status APIs) and match each M-Pesa receipt number against the records in your payments table.

A daily reconciliation job should:

  1. Pull all payments from your database for the day, grouped by provider.
  2. For Paystack payments, verify each transaction via the Paystack Verify Transaction API (GET /transaction/verify/:reference). Compare the amount and status.
  3. For Daraja payments, query the Transaction Status API with the CheckoutRequestID. Confirm the ResultCode is 0 and the amount matches.
  4. Flag discrepancies. Any payment in your database that the provider does not recognize (or vice versa) needs investigation.
  5. Generate a combined report. Total revenue for the day, broken down by provider. This feeds into your accounting.
async function dailyReconciliation(date) {
  const payments = await db.payments.findAll({
    where: { status: 'success', confirmed_at: { gte: startOfDay(date), lt: endOfDay(date) } },
  });

  const report = { paystack: [], daraja: [], discrepancies: [] };

  for (const payment of payments) {
    if (payment.provider === 'paystack') {
      const verified = await verifyPaystackTransaction(payment.reference);
      if (verified.amount !== payment.amount || verified.status !== 'success') {
        report.discrepancies.push({ payment, reason: 'Amount or status mismatch' });
      } else {
        report.paystack.push(payment);
      }
    } else if (payment.provider === 'daraja') {
      const verified = await queryDarajaTransactionStatus(payment.provider_ref);
      if (verified.ResultCode !== '0') {
        report.discrepancies.push({ payment, reason: 'Daraja status mismatch' });
      } else {
        report.daraja.push(payment);
      }
    }
  }

  return report;
}

A few practical notes:

  • Run reconciliation during off-peak hours. Hitting both APIs for every transaction takes time.
  • Keep reconciliation reports. When a customer disputes a charge two months later, you need to know which provider processed it and whether the reconciliation passed.
  • If you process high volume, consider a queue-based approach instead of iterating through payments synchronously.

Testing the Fallback

A fallback you have never tested is a fallback that will not work when you need it. Here is how to verify your setup.

Unit tests for the circuit breaker. Test each state transition: CLOSED to OPEN after N failures, OPEN to HALF_OPEN after the timeout, HALF_OPEN to CLOSED on success, HALF_OPEN back to OPEN on failure. These tests run fast and do not need real API calls.

Integration tests with mocked providers. Mock the Paystack charge function to throw errors and verify that the router falls back to Daraja. Mock Daraja to succeed and confirm the payment record shows provider: 'daraja'.

Staging environment dry run. If you have a staging environment, temporarily block outbound traffic to Paystack (using a firewall rule or by setting an invalid API key). Run test transactions and confirm they route through Daraja sandbox. Then unblock Paystack and confirm the circuit breaker recovers.

Production validation. On a quiet day, you can manually set the circuit breaker to OPEN for a short window and confirm that real transactions go through Daraja. Monitor closely and have someone ready to revert. Some teams schedule this as a quarterly "failover drill."

Monitoring and alerting. Set up alerts for:

  • Circuit breaker state changes. You want to know immediately when the circuit opens or closes.
  • Daraja fallback usage. If the fallback starts processing transactions, someone should investigate why.
  • Both-provider-down scenarios. If Daraja also fails after Paystack is down, that needs immediate human attention.

Log every provider decision. When you are debugging at 2 AM why a customer did not receive their ticket, you need to know which provider processed the charge, what the response was, and whether the webhook arrived.

Limitations and Honest Assessment

This approach works, but it comes with real costs. Be honest with yourself about whether the complexity is worth it for your specific product.

Added complexity. You are now maintaining two payment integrations, two webhook handlers, two sets of credentials, and a reconciliation system that spans both. Every feature you add to your payment flow (refunds, receipts, reporting) now needs to work with both providers.

Daraja maintenance burden. Daraja requires more hands-on maintenance than Paystack. OAuth tokens expire. Callback URLs need to be updated if your infrastructure changes. Certificate issues come up. If you are not already running Daraja in production, adding it as a "just in case" fallback is a significant commitment.

Different customer experience. When the fallback kicks in, the customer sees your own paybill or till number on the STK push instead of Paystack's. For most customers, this does not matter. But if your support team is trained around "look for Paystack on your M-Pesa statement," the Daraja payments will cause confusion.

Reconciliation overhead. This is ongoing, not one-time. Every day, you need to reconcile across two providers. Every month-end, your finance team needs to account for money that arrived through two different settlement channels.

For many products, the right answer is to accept the risk of occasional Paystack downtime rather than building and maintaining this fallback. The fallback makes sense when:

  • Payment availability is a hard business requirement (transport, emergency services, time-sensitive events).
  • You already run Daraja for other reasons and the incremental cost of the fallback is low.
  • Transaction volume is high enough that even short outages mean significant revenue loss.

If none of those apply, a simpler approach might be better: monitor Paystack status, show a "try again in a few minutes" message during rare outages, and move on. Not every problem needs an engineering solution. Sometimes the business risk is small enough to accept.

Key Takeaways

  • Every payment provider has downtime. If M-Pesa is your primary revenue channel, a single point of failure on Paystack means lost sales. A Daraja fallback keeps money flowing.
  • A circuit breaker pattern monitors Paystack health and automatically switches to direct Daraja STK Push after consecutive failures, then switches back when Paystack recovers.
  • Your payments database needs a provider column. Every transaction must record whether it went through Paystack or Daraja so reconciliation works correctly.
  • Health checks should be lightweight. Ping a low-cost Paystack endpoint on a schedule rather than discovering failures when a real customer is trying to pay.
  • Reconciliation across two providers is the hardest part. You need to pull settlements from Paystack and M-Pesa statements from Daraja and match them against your single payments table.
  • The fallback only covers M-Pesa. If Paystack is down and a customer wants to pay by card or Pesalink, you cannot fall back to Daraja for those methods.
  • Test the fallback regularly. A fallback you have never triggered in production is a fallback that does not work.

Frequently Asked Questions

How often does Paystack actually go down?
Paystack is generally reliable, and significant outages are uncommon. But "uncommon" is not "never." Degraded performance, slow responses, and brief maintenance windows do happen. Check status.paystack.com for their incident history. Whether the risk justifies a fallback depends on your business context, not on a general reliability statistic.
Can I use the same callback URL for Paystack webhooks and Daraja callbacks?
Technically you could parse the request body to determine which provider sent it, but this is a bad idea. The formats are completely different, the authentication mechanisms are different, and debugging becomes harder. Use separate endpoints: /webhooks/paystack and /webhooks/daraja/stk. Keep them clean and independent.
What happens to Paystack Subscriptions during a fallback?
If a customer is on a Paystack Subscription and Paystack is down during a renewal attempt, Paystack will retry according to its own retry schedule when it recovers. Your fallback does not need to handle subscription renewals. Those are managed entirely by Paystack. The fallback only applies to charges you initiate from your application.
Does the circuit breaker pattern work for non-M-Pesa payment methods?
The circuit breaker itself is provider-agnostic. But the fallback destination (Daraja) only supports M-Pesa. For card payments and Pesalink, you would need a different fallback provider like IntaSend or Flutterwave, which adds even more complexity. Most teams only build the M-Pesa fallback since it covers the majority of their Kenyan transactions.
How do I handle refunds when a payment went through Daraja instead of Paystack?
You cannot use Paystack's refund API for a payment that went through Daraja. For Daraja payments, you handle refunds via the B2C (Business to Customer) API, sending money back to the customer's M-Pesa wallet. Your refund logic needs to check the provider column in your payments table and call the correct API.

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