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.
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.
Before you start building, you need to understand the boundaries of this approach.
What the fallback covers:
What the fallback does NOT cover:
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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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:
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.
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:
/bank or /bank?currency=KES. It returns a list of banks and costs nothing. Do not use the charge endpoint for health checks.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.
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:
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.
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:
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.',
});
}
});
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:
Your application code should never assume the provider. Always check the provider column before interpreting the provider_ref or provider_meta fields.
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:
200 OK. Daraja wants a JSON response with ResultCode: 0.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:
GET /transaction/verify/:reference). Compare the amount and status.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:
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:
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.
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:
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.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course