Paystack mobile money charges (M-Pesa, MTN MoMo, Airtel Money) stay pending because the customer has not completed the payment on their phone. Unlike card payments, mobile money requires the customer to actively approve the transaction outside your app by entering their PIN on the USSD prompt or mobile money menu. If they ignore it, close the prompt, or have network issues, the charge stays pending until it times out (usually 5 to 15 minutes depending on the provider). Do not poll the verify endpoint in a tight loop. Use webhooks to get notified when the charge resolves, and show the customer a clear "waiting for approval" screen.
Card payments on Paystack resolve quickly. The customer enters their card details, the bank authorizes (or declines) within seconds, and you get a result. Mobile money is fundamentally different because it involves a second device and a second action from the customer.
Here is what happens when you initiate a mobile money charge through Paystack:
The gap between steps 3 and 4 is where "stuck at pending" happens. The charge is waiting for the customer to do something on their phone. Your server has no control over this step. The customer might be in a meeting, might not have noticed the prompt, might have insufficient funds and is deciding whether to top up, or might have terrible network coverage.
1. Customer did not complete the prompt. This is the cause about 80% of the time. The USSD prompt appeared on their phone, but they did not enter their PIN. Maybe they got distracted, maybe they changed their mind, maybe they did not understand what to do. The charge will stay pending until the prompt times out.
2. Customer did not receive the prompt. Network issues can prevent the STK Push or USSD prompt from reaching the customer's phone. This is especially common in areas with poor network coverage. The customer is waiting, your system is waiting, but the telco never delivered the prompt.
3. Provider timeout. Each mobile money provider has its own timeout window. If the customer does not respond within that window, the charge is automatically marked as failed. But during the timeout period, the charge stays pending.
4. Network delay between provider and Paystack. The customer completed the payment on their phone (they saw the success message), but the confirmation from the telco to Paystack is delayed due to network issues. The charge stays pending on Paystack's side even though the money has left the customer's wallet.
5. Provider system downtime. Mobile money systems experience downtime more frequently than card networks. During maintenance windows or outages, charges can get stuck in a pending state for extended periods.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
The worst thing you can do is show a blank loading spinner with no context. The customer does not know if the payment went through, if they need to do something, or if it failed. Here is what to show instead.
Immediately after initiating the charge:
Show a message like: "We sent a payment request to your phone. Please check your phone and enter your M-Pesa PIN to complete the payment."
After 30 seconds with no response:
Show a helper message: "Did not receive the prompt? Check that your phone has network coverage and try again. You can also dial *334# to check your M-Pesa balance."
After the provider timeout (60 seconds for M-Pesa):
Show: "The payment request timed out. This usually means the prompt was not completed on your phone. You can try again."
// Frontend: polling with user-friendly status messages
async function waitForMobileMoneyPayment(reference) {
const maxAttempts = 12; // 12 attempts x 10 seconds = 2 minutes
const interval = 10000; // 10 seconds between checks
for (let attempt = 0; attempt < maxAttempts; attempt++) {
// Update UI based on elapsed time
const elapsed = attempt * 10;
if (elapsed < 30) {
updateUI('Waiting for you to approve on your phone...');
} else if (elapsed < 60) {
updateUI('Still waiting. Please check your phone for the payment prompt.');
} else {
updateUI('Taking longer than usual. The prompt may have expired. You can try again.');
}
// Check status
const response = await fetch(`/api/payment/status/${reference}`);
const data = await response.json();
if (data.status === 'success') {
updateUI('Payment confirmed. Thank you!');
return { success: true };
}
if (data.status === 'failed') {
updateUI('Payment was not completed. ' + (data.message || 'Please try again.'));
return { success: false, message: data.message };
}
// Still pending, wait before next check
await new Promise(resolve => setTimeout(resolve, interval));
}
// Timeout reached
updateUI('We could not confirm your payment. If money was deducted, it will be refunded automatically. Contact support if you need help.');
return { success: false, message: 'timeout' };
}
function updateUI(message) {
document.getElementById('payment-status').textContent = message;
}
You have two ways to know when a mobile money charge resolves: polling the verify endpoint, or listening for webhooks. The right answer is: use both, but rely primarily on webhooks.
Webhooks (primary): Configure your webhook endpoint to handle charge.success and charge.failed events. When the mobile money charge resolves, Paystack sends the event to your webhook URL. Your backend processes it, updates the database, and the frontend picks up the change.
Polling (secondary, for real-time UI): While the customer is staring at the "waiting" screen, poll your own backend (not Paystack directly) every 10 to 15 seconds to check if the webhook has arrived and updated the database. This gives the customer faster feedback.
const express = require('express');
const crypto = require('crypto');
const app = express();
// In-memory store for demo. Use your database in production.
const pendingCharges = new Map();
// Webhook handler (primary)
app.post('/webhooks/paystack', express.raw({ type: 'application/json' }), (req, res) => {
res.sendStatus(200); // Always respond immediately
const hash = crypto
.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY)
.update(req.body)
.digest('hex');
if (hash !== req.headers['x-paystack-signature']) return;
const event = JSON.parse(req.body.toString());
if (event.event === 'charge.success') {
const ref = event.data.reference;
console.log('Mobile money charge succeeded:', ref);
pendingCharges.set(ref, { status: 'success', data: event.data });
// Update your database, grant value, notify customer
}
if (event.event === 'charge.failed') {
const ref = event.data.reference;
console.log('Mobile money charge failed:', ref);
pendingCharges.set(ref, {
status: 'failed',
message: event.data.gateway_response,
});
}
});
// Status endpoint (for frontend polling)
app.get('/api/payment/status/:reference', async (req, res) => {
const ref = req.params.reference;
// Check local cache first (updated by webhook)
const cached = pendingCharges.get(ref);
if (cached) {
return res.json(cached);
}
// Fallback: check Paystack API directly
try {
const response = await axios.get(
`https://api.paystack.co/transaction/verify/${ref}`,
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
}
);
const data = response.data.data;
res.json({ status: data.status, message: data.gateway_response });
} catch (error) {
res.json({ status: 'pending', message: 'Waiting for payment confirmation' });
}
});
app.listen(3000);
Do not poll Paystack directly from the frontend. That exposes your secret key and hammers their API. Poll your own backend, which checks the database (updated by webhooks) and only falls back to the Paystack API if needed.
Different mobile money providers have different timeout windows. Here are the typical values:
On your side, set a maximum wait time of 5 minutes for the frontend polling loop. After 5 minutes, stop polling and show the customer a message. But do not cancel the transaction on your end. The webhook might still arrive minutes later if there was a network delay between the provider and Paystack.
// Backend: handle stale pending transactions
async function cleanupStalePendingCharges() {
const fifteenMinutesAgo = new Date(Date.now() - 15 * 60 * 1000);
// Find charges that have been pending for more than 15 minutes
// Replace with your actual database query
const staleCharges = await db.query(
'SELECT reference FROM charges WHERE status = $1 AND created_at < $2',
['pending', fifteenMinutesAgo]
);
for (const charge of staleCharges.rows) {
// Verify with Paystack to get the final status
try {
const response = await axios.get(
`https://api.paystack.co/transaction/verify/${charge.reference}`,
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
}
);
const status = response.data.data.status;
if (status === 'success') {
console.log(`Late success for ${charge.reference}. Granting value now.`);
await grantValue(charge.reference, response.data.data);
} else if (status === 'failed' || status === 'abandoned') {
console.log(`Charge ${charge.reference} confirmed as ${status}.`);
await markChargeFailed(charge.reference, status);
}
// If still "pending" after 15 minutes, log but do not auto-fail
// The provider may still resolve it
} catch (error) {
console.error(`Error checking stale charge ${charge.reference}:`, error.message);
}
}
}
// Run every 5 minutes
setInterval(cleanupStalePendingCharges, 5 * 60 * 1000);
Sometimes the charge is stuck pending because the mobile money provider itself is having issues. This is outside your control and outside Paystack's control. Telco systems in Africa go down more frequently than card networks, especially during peak periods (month-end salary days, holiday seasons).
Signs of a provider issue:
What to do:
Mobile money transactions need extra reconciliation because of the timing gaps. A charge might succeed on the provider side but the webhook arrives late. Or a charge might fail but your cleanup job ran before the failure notification arrived. You need a daily reconciliation process.
async function reconcileMobileMoneyCharges() {
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
const today = new Date();
// Get all mobile money transactions from Paystack
const response = await axios.get('https://api.paystack.co/transaction', {
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
params: {
channel: 'mobile_money',
from: yesterday.toISOString(),
to: today.toISOString(),
status: 'success',
perPage: 200,
},
});
const paystackTransactions = response.data.data;
console.log(`Paystack reports ${paystackTransactions.length} successful mobile money charges`);
let mismatches = 0;
for (const txn of paystackTransactions) {
// Check your database
const localRecord = await db.query(
'SELECT status FROM charges WHERE reference = $1',
[txn.reference]
);
if (localRecord.rows.length === 0) {
console.warn(`MISSING: ${txn.reference} - ${txn.amount / 100} - not in our database`);
mismatches++;
// Create the record and grant value
await processLateCharge(txn);
} else if (localRecord.rows[0].status !== 'success') {
console.warn(`MISMATCH: ${txn.reference} - Paystack says success, we say ${localRecord.rows[0].status}`);
mismatches++;
// Update status and grant value
await fixChargeStatus(txn);
}
}
console.log(`Reconciliation complete. ${mismatches} discrepancies found and fixed.`);
}
reconcileMobileMoneyCharges().catch(console.error);
Run this reconciliation job at least once daily. For high-volume mobile money integrations, run it every few hours. The job catches any charges where the webhook was lost, your server was down when the webhook arrived, or any other gap in the real-time flow.
Some "stuck pending" issues are caused by incorrect charge initiation. Here is the correct way to start a mobile money charge through Paystack.
async function initiateMobileMoneyCharge(email, amount, phone, provider) {
// provider: 'mpesa' for Kenya, 'mtn' for Ghana, etc.
try {
const response = await axios.post(
'https://api.paystack.co/charge',
{
email: email,
amount: amount, // In lowest currency unit (kobo, pesewas, cents)
mobile_money: {
phone: phone, // Format: +254... for Kenya, +233... for Ghana
provider: provider,
},
currency: provider === 'mpesa' ? 'KES' : 'GHS', // Match provider to currency
},
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
}
);
const data = response.data.data;
console.log('Charge initiated');
console.log('Status:', data.status); // "pay_offline" or "send_otp"
console.log('Reference:', data.reference);
console.log('Display text:', data.display_text);
// Store the reference for tracking
await db.query(
'INSERT INTO charges (reference, email, amount, provider, status, created_at) VALUES ($1, $2, $3, $4, $5, NOW())',
[data.reference, email, amount, provider, 'pending']
);
return {
reference: data.reference,
message: data.display_text || 'Please check your phone to complete the payment.',
};
} catch (error) {
const errData = error.response?.data;
console.error('Charge failed:', errData?.message || error.message);
throw new Error(errData?.message || 'Failed to initiate mobile money charge');
}
}
// Usage
initiateMobileMoneyCharge(
'customer@example.com',
50000, // 500 KES in cents
'+254712345678',
'mpesa'
).catch(console.error);
Common initiation mistakes:
Test the full mobile money flow in Paystack sandbox.
In production, monitor the percentage of mobile money charges that stay pending for more than 5 minutes. If this number spikes, it usually indicates a provider issue. Set up an alert for when the pending rate exceeds your normal baseline.
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