Kenyan networks are good in cities and unreliable everywhere else. Payment systems must handle dropped connections, high latency, STK push delays, and customers on 2G. Set server-side timeouts to at least 30 seconds for M-Pesa operations. Make every payment request idempotent using unique references. Build your UI to survive connection drops mid-payment. Send SMS confirmations as a fallback channel. Keep payloads small to reduce data costs and loading times.
Kenya has good mobile internet coverage by African standards. 4G covers most urban areas. Safaricom, Airtel, and Telkom have invested heavily in infrastructure. Nairobi, Mombasa, Kisumu, and other major towns have reliable connectivity most of the time.
But "most of the time" is not "all of the time," and "major towns" is not "everywhere." Here is what your payment system actually faces:
These are not hypothetical problems. They are the normal operating conditions for a large segment of your user base. Every design decision in your payment flow should account for them.
The default HTTP timeout in most libraries is 30 seconds. For payment operations in Kenya, you need to think about timeouts at multiple levels.
Server-side timeouts (your backend calling Paystack):
// Configure axios with appropriate timeouts for Kenyan conditions
const paystackClient = axios.create({
baseURL: 'https://api.paystack.co',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
timeout: 45000, // 45 seconds for payment operations
});
// For M-Pesa charges specifically, use a longer timeout
async function initiateMpesaCharge(phone, amount, email, reference) {
try {
const response = await paystackClient.post('/charge', {
email,
amount,
currency: 'KES',
reference,
mobile_money: { phone, provider: 'mpesa' },
}, {
timeout: 60000, // 60 seconds for M-Pesa specifically
});
return { success: true, data: response.data.data };
} catch (error) {
if (error.code === 'ECONNABORTED') {
// Timeout: the charge might still be processing
return {
success: false,
timeout: true,
reference, // Important: keep the reference to check later
message: 'Request timed out. The payment may still be processing.',
};
}
throw error;
}
}
Client-side timeouts (the customer's browser or app):
STK push timeout:
After you call the Paystack Charge API with M-Pesa, the customer receives an STK push on their phone. They have to read the prompt, confirm the amount, and enter their PIN. This takes 10 to 30 seconds for a typical customer. On a slow network, the push itself may take 5 to 15 seconds to arrive.
Your UI should tell the customer: "Check your phone for the M-Pesa prompt" and show a waiting state for at least 60 seconds before suggesting they try again. Some implementations poll the Paystack Verify endpoint every 5 seconds during this wait to detect successful payment.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
When a network request fails, the natural reaction is to retry. In payment systems, retrying without idempotency guarantees means you might charge the customer twice. This is the single most important rule for payment integrations on unreliable networks.
The rule: every payment request must include a unique, client-generated reference. If you retry the request, use the same reference.
// Generate a unique reference before the first attempt
function generatePaymentReference(orderId) {
return `order-${orderId}-${Date.now()}`;
}
// Retry logic with the same reference
async function chargeWithRetry(paymentDetails, maxRetries = 3) {
const reference = generatePaymentReference(paymentDetails.orderId);
let lastError = null;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const result = await paystackClient.post('/charge', {
...paymentDetails,
reference, // Same reference on every attempt
});
return { success: true, data: result.data.data };
} catch (error) {
lastError = error;
// Do NOT retry if Paystack explicitly rejected the charge
if (error.response?.status === 400) {
return {
success: false,
retryable: false,
message: error.response.data.message,
};
}
// Retry on network errors and timeouts
if (error.code === 'ECONNABORTED' || error.code === 'ECONNRESET' || !error.response) {
console.log(`Attempt ${attempt} failed (network error). Retrying...`);
// Exponential backoff: 2s, 4s, 8s
if (attempt < maxRetries) {
await new Promise(resolve => setTimeout(resolve, 2000 * Math.pow(2, attempt - 1)));
}
continue;
}
// Do not retry server errors from Paystack (500s)
// The charge may have been created. Verify instead of retrying.
if (error.response?.status >= 500) {
return {
success: false,
shouldVerify: true,
reference,
message: 'Paystack server error. Verify the transaction status.',
};
}
throw error;
}
}
// All retries exhausted
return {
success: false,
shouldVerify: true,
reference,
message: 'All retries failed. Verify the transaction status before retrying.',
};
}
Key principles:
The worst scenario: the customer taps "Pay," the connection drops, and neither the customer nor your frontend knows what happened. Here is how to handle this.
On the client side:
// Client-side payment with connection-drop recovery
async function processPayment(orderId, amount) {
const reference = `order-${orderId}-${Date.now()}`;
// Save the reference locally before making the request
localStorage.setItem('pending_payment_ref', reference);
localStorage.setItem('pending_payment_order', orderId);
showLoadingState('Processing payment...');
try {
const response = await fetch('/api/charge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderId, amount, reference }),
signal: AbortSignal.timeout(45000),
});
const data = await response.json();
if (data.success) {
showMpesaPrompt('Check your phone for the M-Pesa prompt');
startPollingForConfirmation(reference);
} else {
showError(data.message);
}
} catch (error) {
if (error.name === 'AbortError' || error.name === 'TypeError') {
// Network error or timeout
showRecoveryUI(reference);
} else {
showError('Something went wrong. Please try again.');
}
}
}
// When the app loads, check for pending payments
function checkForPendingPayments() {
const pendingRef = localStorage.getItem('pending_payment_ref');
const pendingOrder = localStorage.getItem('pending_payment_order');
if (pendingRef) {
// Ask the server to check the status
verifyPaymentStatus(pendingRef, pendingOrder);
}
}
async function verifyPaymentStatus(reference, orderId) {
try {
const response = await fetch(`/api/verify-payment/${reference}`);
const data = await response.json();
if (data.status === 'success') {
showSuccess('Your payment was successful!');
localStorage.removeItem('pending_payment_ref');
localStorage.removeItem('pending_payment_order');
} else if (data.status === 'pending') {
showPendingUI('Your payment is still being processed. We will notify you.');
} else {
showRetryUI('Your previous payment did not go through. Would you like to try again?');
localStorage.removeItem('pending_payment_ref');
localStorage.removeItem('pending_payment_order');
}
} catch {
// Still offline. Will check again next time the app loads.
}
}
// Check on app load
checkForPendingPayments();
The pattern is simple: before making the payment request, save the reference locally. When the app loads (or comes back online), check if there is a pending reference and verify its status. This handles the case where the customer's connection drops, they close the browser, and come back hours later.
On the server side:
// Verification endpoint
app.get('/api/verify-payment/:reference', async (req, res) => {
const { reference } = req.params;
// Check your database first
const order = await getOrderByReference(reference);
if (order?.status === 'paid') {
return res.json({ status: 'success' });
}
// If not yet marked as paid, check with Paystack
try {
const paystackRes = await fetch(
`https://api.paystack.co/transaction/verify/${reference}`,
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
}
);
const data = await paystackRes.json();
if (data.data?.status === 'success') {
// Payment went through but webhook might not have arrived yet
await updateOrderStatus(order.id, 'paid', {
paystackRef: reference,
verifiedManually: true,
});
return res.json({ status: 'success' });
}
if (data.data?.status === 'pending' || data.data?.status === 'ongoing') {
return res.json({ status: 'pending' });
}
return res.json({ status: 'failed' });
} catch {
return res.json({ status: 'unknown' });
}
});
This verification endpoint closes the loop. Even if the webhook was delayed or the client lost connection, the customer can always check whether their payment went through.
True offline payment is not possible with Paystack (you need a network connection to initiate a charge), but you can design flows that tolerate intermittent connectivity.
Pattern 1: Queue orders offline, pay when online.
Let the customer browse products and build a cart while offline (service workers and cached product data). When they go to checkout, queue the order locally. When connectivity returns, submit the order and initiate the payment. This is useful for mobile apps used by sales agents in rural areas.
Pattern 2: Pre-generated payment links.
If you know what the customer will pay (a fixed monthly subscription, a known invoice), generate the Paystack payment link while you have connectivity. Store the link. Share it with the customer (via SMS, WhatsApp, or in-app) even if connectivity is intermittent. The customer only needs connectivity at the moment they tap the link and complete payment.
Pattern 3: USSD fallback.
USSD works on 2G without a data connection. If your customer is in an area with no data but has GSM signal, they can initiate payment through a USSD flow that triggers the Paystack charge on your server. See USSD Plus Paystack: Reaching Feature Phone Users.
Pattern 4: STK push as the offline-friendly option.
M-Pesa STK push is delivered over the GSM network, not over the internet. The customer does not need a data connection to receive and respond to the push. They only need GSM signal. Your server needs connectivity to call the Paystack API, but the customer does not. This is one reason M-Pesa works where card payments do not: the customer's side of the transaction is GSM-based.
Push notifications, email, and in-app messages all require a data connection to reach the customer. SMS does not. It works on 2G, on feature phones, and in areas with minimal coverage.
M-Pesa already sends the customer an SMS when they complete a payment. But that SMS confirms the M-Pesa transaction, not your order. The customer knows they paid, but they do not know if your system received and processed the payment.
Sending your own SMS confirmation closes that gap:
// Send SMS confirmation after successful payment
// Using Africa's Talking SMS API as an example
async function sendSmsConfirmation(phone, orderId, amount) {
const message = `Payment of KES ${amount.toLocaleString()} received for order ${orderId}. Thank you for your purchase.`;
try {
const response = await fetch('https://api.africastalking.com/version1/messaging', {
method: 'POST',
headers: {
apiKey: process.env.AT_API_KEY,
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
body: new URLSearchParams({
username: process.env.AT_USERNAME,
to: phone,
message,
from: process.env.AT_SENDER_ID || '',
}),
});
const data = await response.json();
return { success: true, data };
} catch (error) {
console.error('SMS sending failed:', error);
// SMS failure should not block the payment flow
return { success: false, error };
}
}
Important details about SMS in the Kenyan context:
On a fast connection, the user taps "Pay" and sees a result in 2 seconds. On a slow connection, 15 to 30 seconds is normal. Without proper feedback, the customer assumes the app is broken and taps "Pay" again, potentially triggering a double charge.
Design your loading states for the slowest connection your users will have:
// Progressive loading states
function showPaymentProgress(stage) {
const messages = {
initiated: 'Processing your payment...',
connecting: 'Connecting to payment service...',
waiting_stk: 'Check your phone for the M-Pesa prompt. Enter your PIN to complete.',
slow: 'Still waiting. This sometimes takes a moment on slower networks.',
very_slow: 'Taking longer than usual. If you entered your PIN, your payment may be processing.',
check: 'You can check your payment status or try again.',
};
updateUI(messages[stage]);
}
// Timer-based progression
function startPaymentTimer() {
showPaymentProgress('initiated');
setTimeout(() => showPaymentProgress('connecting'), 3000);
setTimeout(() => showPaymentProgress('waiting_stk'), 8000);
setTimeout(() => showPaymentProgress('slow'), 20000);
setTimeout(() => showPaymentProgress('very_slow'), 35000);
setTimeout(() => showPaymentProgress('check'), 60000);
}
These timers should be cancelled when the payment succeeds. If the webhook or polling detects a successful payment at second 12, immediately show the success state. Do not keep showing "Still waiting..." after the payment has already completed.
Kenyan customers on prepaid data plans pay for every byte. A checkout page that loads 3MB of JavaScript, fonts, and images costs them money before they even start paying you. Respect their data.
Practical optimizations:
You can detect slow connections on the client and adjust accordingly:
// Detect connection quality and adapt
function getConnectionQuality() {
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (!connection) return 'unknown';
// effectiveType: 'slow-2g', '2g', '3g', '4g'
const type = connection.effectiveType;
if (type === 'slow-2g' || type === '2g') return 'low';
if (type === '3g') return 'medium';
return 'high';
}
// Adapt the checkout based on connection quality
function renderCheckout() {
const quality = getConnectionQuality();
if (quality === 'low') {
// Minimal checkout: no images, no animations, system fonts
renderMinimalCheckout();
} else if (quality === 'medium') {
// Standard checkout with compressed images
renderStandardCheckout();
} else {
// Full experience
renderFullCheckout();
}
}
Where your servers are located matters for latency. If your backend is hosted in US-East and your customer is in Nairobi, every API call travels across the Atlantic and back. That is 200 to 400ms of latency per request, on top of whatever processing time the request takes.
Options for reducing latency:
A practical improvement: if your checkout flow makes three sequential calls (load cart from your API, initialize transaction on Paystack, redirect to checkout), and each call takes 300ms on a good connection, that is 900ms minimum. On a 3G connection with 500ms latency per hop, it becomes 2.5 seconds. Reduce the number of sequential calls by combining them or pre-computing values.
You cannot ship reliable software for bad networks if you only test on your office WiFi. Here is how to test properly.
Make slow-network testing a regular part of your QA process, not a one-time exercise. Every feature that touches the payment flow should be tested on slow networks before it ships.
For related topics on building reliable payment systems in Kenya:
Building for African network conditions is a core skill. The McTaba 26-week bootcamp teaches you to build real products that work for real users on real networks in Kenya and across Africa.
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