Paystack Mobile Money Charge Stuck at Pending
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.
Why Mobile Money Charges Behave Differently from Card Payments
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:
- Your server sends a charge request to Paystack with the customer's phone number and the mobile money provider (e.g., "mpesa" for Kenya, "mtn" for Ghana).
- Paystack contacts the mobile money provider (Safaricom for M-Pesa, MTN for MoMo, etc.).
- The provider sends a payment prompt to the customer's phone. For M-Pesa, this is an STK Push. For MTN MoMo, it is a USSD prompt or app notification.
- The customer sees the prompt, reviews the amount and merchant name, and enters their PIN to approve.
- The provider confirms the payment to Paystack.
- Paystack updates the transaction status and sends you a webhook.
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.
Why the Charge Stays Pending: The Five Causes
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.
What to Show the Customer While the Charge Is Pending
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;
}
Polling vs Webhooks: The Right Approach
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.
How Long to Wait Before Giving Up
Different mobile money providers have different timeout windows. Here are the typical values:
- M-Pesa (Kenya, via Paystack): STK Push prompt times out in about 60 seconds. After that, the transaction is marked as failed.
- MTN MoMo (Ghana): USSD prompt can last 2 to 5 minutes depending on the session type.
- Vodafone Cash (Ghana): Similar to MTN, 2 to 5 minutes.
- AirtelTigo Money (Ghana): Typically 2 to 3 minutes.
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);
When the Problem Is the Network or Provider
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:
- Multiple customers on the same provider (e.g., all MTN users in Ghana) are experiencing stuck charges simultaneously
- Charges that normally resolve in 30 seconds are taking 10+ minutes
- Customers report seeing the prompt and entering their PIN, but nothing happens
What to do:
- Check the Paystack status page (status.paystack.com) for any reported incidents.
- Check the mobile money provider's channels for reported downtime.
- If confirmed, temporarily disable the affected payment channel in your checkout and show a message: "M-Pesa payments are temporarily unavailable. Please try card payment or bank transfer."
- Do not retry stuck charges automatically. If the provider is down, retries will also fail and you risk duplicate charges when the system comes back.
Reconciling Mobile Money Charges After the Dust Settles
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.
Initiating Mobile Money Charges Correctly
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:
- Wrong phone number format. Use the international format with country code (+254 for Kenya, +233 for Ghana).
- Wrong currency for the provider. M-Pesa uses KES, MTN MoMo in Ghana uses GHS. Mismatched currency and provider will fail.
- Amount too low. Each provider has minimum transaction amounts.
How to Verify Your Pending Charge Handling Works
Test the full mobile money flow in Paystack sandbox.
- Initiate a mobile money charge with a test phone number in sandbox mode.
- Confirm your frontend shows the "waiting for approval" message with clear instructions.
- Confirm your frontend polls at 10 to 15 second intervals, not faster.
- Wait for the charge to resolve (sandbox will simulate success or failure).
- Confirm your webhook handler receives and processes the event.
- Confirm your frontend updates from "waiting" to "success" or "failed."
- Test the timeout scenario: initiate a charge and do not complete it. Confirm your frontend shows the timeout message after 5 minutes.
- Run your reconciliation job and confirm it catches any discrepancies.
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.
Key Takeaways
- ✓Mobile money charges are inherently asynchronous. The charge stays pending until the customer completes the payment on their phone. This can take seconds or minutes.
- ✓The most common reason for a stuck pending charge is that the customer never completed the prompt. They got distracted, closed the USSD session, or did not receive the push notification.
- ✓Do not poll the verify endpoint every second. Use webhooks to receive charge.success or charge.failed events. If you must poll, use intervals of 10 to 15 seconds.
- ✓Timeouts vary by provider: M-Pesa STK Push times out in about 60 seconds, MTN MoMo prompts can last 2 to 5 minutes, and some providers hold for up to 15 minutes.
- ✓Always show customers a clear "waiting for you to approve on your phone" message with instructions on what to do if they did not receive the prompt.
- ✓Build reconciliation for mobile money transactions. Network issues between the telco and Paystack can cause delayed confirmations that arrive after your timeout.
- ✓Never grant value based on the initialize response. Only grant value after verifying a "success" status via webhook or the verify API.
Frequently Asked Questions
- How long should I wait for a Paystack mobile money charge to resolve?
- On the frontend, wait up to 5 minutes with polling before showing a timeout message. On the backend, keep the charge in "pending" status for up to 24 hours before marking it as abandoned. The webhook can arrive late if there were network issues between the provider and Paystack. Your reconciliation job should catch any charges that resolved after your frontend timed out.
- Should I use polling or webhooks for mobile money status updates?
- Use both. Webhooks are the primary mechanism. Your backend should listen for charge.success and charge.failed webhook events and update the database. For real-time frontend updates, poll your own backend every 10 to 15 seconds to check if the webhook has updated the charge status. Do not poll the Paystack API directly from the frontend.
- The customer says they paid but the charge is still pending. What happened?
- This usually means the confirmation from the mobile money provider to Paystack is delayed due to network issues. Ask the customer for their mobile money confirmation message or transaction ID. Check the Paystack dashboard. If the charge still shows pending, contact Paystack support with the reference number and the customer mobile money receipt.
- Can I cancel a pending mobile money charge?
- You cannot cancel a pending mobile money charge through the Paystack API. The charge will resolve on its own when the provider timeout expires (typically 1 to 5 minutes) or when the customer completes or declines the payment. Do not initiate a new charge for the same customer while the first one is still pending, as this can lead to duplicate charges if both resolve successfully.
- How do I handle duplicate mobile money charges?
- Use unique references for each charge and implement idempotent webhook handlers. If a customer accidentally gets charged twice, one of the charges will usually fail at the provider level because the prompt was already completed. If both succeed, use the Paystack Refund API to reverse one of them. Your reconciliation job should flag transactions where the same customer was charged the same amount within a short window.
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