Pending Transactions: Handling the Paystack Grey Zone
When a Paystack transaction has not reached a final status (success, failed, or abandoned), do not grant value and do not mark it as failed. Show the customer a "processing" page and poll the verify endpoint at intervals. Set a timeout after which you mark the transaction as expired. Bank transfers and USSD payments are most likely to enter this grey zone.
What Causes a Transaction to Stay Pending
Card payments on Paystack typically resolve in under 10 seconds. The customer enters their card, the bank authorizes (or declines), and you get a final status almost immediately. But several payment channels do not work this way.
Bank transfers. Paystack generates a temporary bank account number. The customer opens their banking app, types in the account number, and initiates a transfer. The bank processes the transfer. Paystack confirms receipt. This chain can take anywhere from 30 seconds to several hours, depending on the bank, the time of day, and whether the customer actually completes the transfer.
USSD payments. The customer dials a USSD code, navigates menu options, enters a PIN, and the bank processes the debit. Each step depends on the customer completing it on their phone. If they get distracted, lose signal, or enter the wrong PIN, the transaction stalls.
Mobile money. Similar to USSD, mobile money payments require the customer to authorize on their phone. Network delays between the mobile money provider and Paystack can add seconds to minutes of lag.
During all of these flows, your system sits in limbo. You initialized a transaction. The customer started paying. But you have not received confirmation yet. This is the grey zone.
For the full picture of transaction statuses and what each means, see the transaction status values guide.
The Two Rules of the Grey Zone
There are exactly two rules for handling pending transactions:
Rule 1: Do not grant value. The payment has not been confirmed. If you give the customer access to their course, ship their product, or add credits to their account, you risk giving away value for free. The payment might never complete. The customer might close the tab and walk away.
Rule 2: Do not mark it as failed. The payment might still go through. If you mark the order as failed and the customer actually paid, you now have a customer who paid but did not receive their product. That is worse than the grey zone itself, because now you have a support ticket and a trust problem.
The correct action is to wait. Update your order to a "processing" state that is distinct from both "paid" and "failed". This state means "we are waiting for confirmation from the payment provider."
// Set order to processing state
async function markAsProcessing(orderId, channel) {
await db.query(
'UPDATE orders SET status = $1, payment_channel = $2, processing_started_at = NOW() WHERE id = $3',
['processing', channel, orderId]
);
}
Your application should know how to display this state to the customer and how to transition out of it when the final status arrives.
Building the Processing Page
When the customer finishes the checkout flow and you do not yet have a final status, show them a processing page. This page should:
- Tell them clearly that their payment is being processed.
- Set expectations for how long it might take. "Bank transfers can take up to 30 minutes" is better than a spinning wheel with no context.
- Give them something to do. "You will receive an email when your payment is confirmed" reduces anxiety.
- Provide a way to check back. "Refresh this page to check your payment status" or auto-poll in the background.
On the backend, serve the processing page from your callback handler when the order is not yet paid:
// Redirect callback for pending transactions
app.get('/payment/callback', async function(req, res) {
var reference = req.query.reference;
var order = await db.query(
'SELECT id, status FROM orders WHERE paystack_reference = $1',
[reference]
);
if (order.rows.length === 0) {
return res.redirect('/payment/error');
}
var row = order.rows[0];
if (row.status === 'paid') {
return res.redirect('/payment/success?order=' + row.id);
}
if (row.status === 'failed' || row.status === 'abandoned') {
return res.redirect('/payment/failed?order=' + row.id);
}
// Still processing
return res.redirect('/payment/processing?order=' + row.id);
});
// Status check endpoint for polling
app.get('/api/order-status', async function(req, res) {
var orderId = req.query.order;
var order = await db.query(
'SELECT status FROM orders WHERE id = $1',
[orderId]
);
if (order.rows.length === 0) {
return res.status(404).json({ error: 'Order not found' });
}
return res.json({ status: order.rows[0].status });
});
The frontend processing page polls the /api/order-status endpoint and redirects to the success or failure page when the status changes.
Polling the Verify Endpoint
While the webhook is the authoritative signal for payment completion, you can also poll the Paystack verify endpoint to check for status changes. This is useful for giving the customer faster feedback on the processing page.
// Server-side polling job for a pending transaction
async function pollPendingTransaction(reference, maxAttempts) {
var delays = [5000, 10000, 30000, 60000, 120000]; // 5s, 10s, 30s, 1min, 2min
var attempt = 0;
while (attempt < maxAttempts) {
var delayMs = delays[Math.min(attempt, delays.length - 1)];
await new Promise(function(resolve) {
setTimeout(resolve, delayMs);
});
try {
var result = await fetch(
'https://api.paystack.co/transaction/verify/'
+ encodeURIComponent(reference),
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
var payload = await result.json();
var status = payload.data ? payload.data.status : null;
if (status === 'success' || status === 'failed' || status === 'abandoned') {
// Final state reached
return { finalStatus: status, data: payload.data };
}
console.log('Poll attempt ' + (attempt + 1) + ' for ' + reference
+ ': status=' + status);
} catch (err) {
console.error('Poll error for ' + reference + ': ' + err.message);
}
attempt++;
}
return { finalStatus: null, timedOut: true };
}
Use increasing intervals between polls. Hitting the verify endpoint every second is wasteful and will get you rate-limited. Start at 5 seconds, increase to 10, then 30, then 60. Most bank transfers resolve within 5 minutes. If it has not resolved after 10 polls (about 6 minutes with increasing delays), stop polling and wait for the webhook.
Do not poll from the client side directly. That would expose your secret key. Poll from your server, and have the client poll your own status endpoint.
Setting Timeouts for Pending Transactions
You cannot wait forever. Every pending transaction needs a timeout after which you take action. The timeout depends on the payment channel:
- Card payments: Should resolve within 60 seconds. If still pending after 2 minutes, something is wrong. Mark as expired and investigate.
- USSD: The USSD session typically expires after 5-10 minutes. If no response after 15 minutes, the customer likely abandoned the session.
- Bank transfer: The customer has a limited window to complete the transfer (often 30 minutes to a few hours, depending on Paystack configuration). Set your timeout slightly beyond this window.
- Mobile money: Similar to USSD. 15-30 minutes is a reasonable timeout.
// Timeout checker - runs on a schedule (e.g., every 5 minutes)
async function expireStalePendingOrders() {
var timeouts = {
card: 2, // minutes
ussd: 15, // minutes
bank_transfer: 120, // minutes (2 hours)
mobile_money: 30, // minutes
};
for (var channel in timeouts) {
var minutes = timeouts[channel];
var stale = await db.query(
'SELECT id, paystack_reference FROM orders '
+ 'WHERE status = $1 AND payment_channel = $2 '
+ 'AND processing_started_at < NOW() - INTERVAL '' + minutes + ' minutes'',
['processing', channel]
);
for (var i = 0; i < stale.rows.length; i++) {
var order = stale.rows[i];
// One final verify check before expiring
try {
var result = await fetch(
'https://api.paystack.co/transaction/verify/'
+ encodeURIComponent(order.paystack_reference),
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
var payload = await result.json();
if (payload.data && payload.data.status === 'success') {
// It actually went through. Process it.
await handleLateSuccess(order.id, payload.data);
continue;
}
} catch (err) {
console.error('Final verify failed for ' + order.paystack_reference);
}
// Mark as expired
await db.query(
'UPDATE orders SET status = $1, expired_at = NOW() WHERE id = $2',
['expired', order.id]
);
console.log('Expired stale ' + channel + ' order: ' + order.id);
}
}
}
The timeout checker does one final verify before expiring the order. If the payment actually went through but the webhook was lost, this catches it. If the verify still shows a non-final status, the order is safely expired.
Communicating with the Customer During the Wait
The grey zone is a UX problem as much as an engineering problem. The customer just parted with their money. They want to know it worked. Silence is the worst response.
Here is what to communicate at each stage:
- Immediately after checkout: "We are confirming your payment. This usually takes less than a minute for card payments, and up to 30 minutes for bank transfers."
- If still pending after 2 minutes: "Your payment is still being processed. You will receive an email at [email] once it is confirmed. You can safely close this page."
- If the payment succeeds: "Your payment has been confirmed. Thank you." Send a confirmation email.
- If the payment fails or expires: "Your payment could not be completed. No money was deducted from your account. Would you like to try again?"
For bank transfers specifically, remind the customer of the details they need:
- The temporary account number they should transfer to.
- The exact amount to transfer (mismatches cause problems, see the overpayment and underpayment guide).
- The bank name.
- The expiry time of the temporary account.
A progress indicator on the processing page, even if it is just "checking..." every few seconds, makes the wait feel shorter. Dead silence makes the customer refresh the page, open a new tab, or call your support line.
Monitoring Your Pending Transaction Volume
Track the number of orders in "processing" state at any given time. A sudden spike means something is wrong. Either a payment channel is having issues, or your webhook endpoint is down and not processing completions.
// Monitoring query - run on a schedule and alert if thresholds are exceeded
async function checkPendingVolume() {
var result = await db.query(
'SELECT payment_channel, COUNT(*) as count, '
+ 'MIN(processing_started_at) as oldest '
+ 'FROM orders WHERE status = $1 GROUP BY payment_channel',
['processing']
);
for (var i = 0; i < result.rows.length; i++) {
var row = result.rows[i];
var ageMinutes = (Date.now() - new Date(row.oldest).getTime()) / 60000;
// Alert if too many pending or if the oldest one is too old
if (row.count > 50 || ageMinutes > 120) {
await sendAlert('High pending transaction volume', {
channel: row.payment_channel,
count: row.count,
oldest_minutes: Math.round(ageMinutes),
});
}
}
}
If you see many bank transfer transactions stuck in processing, the likely cause is customers who initiated a transfer but never completed it. If you see many card transactions stuck, that is abnormal and probably means your webhook endpoint is not receiving or processing events. Check your webhook logs on the Paystack dashboard.
For a complete payment monitoring setup, see the admin payments dashboard guide.
Key Takeaways
- ✓Pending transactions are most common with bank transfers, USSD payments, and mobile money, where confirmation takes longer than card payments.
- ✓Never grant value for a transaction that has not reached "success" status. Never mark it as failed either. Wait.
- ✓Show the customer a clear "processing" page that explains what is happening and sets expectations for how long it might take.
- ✓Poll the Paystack verify endpoint at increasing intervals (5s, 10s, 30s, 60s) to check for status updates.
- ✓Set a hard timeout (24-48 hours for bank transfers, 5-10 minutes for USSD) after which you mark the order as expired and alert your team.
- ✓Always handle the webhook as the authoritative signal. Polling is a fallback for UX, not a replacement for webhooks.
Frequently Asked Questions
- What if the customer claims they paid but my system still shows "processing"?
- Call the Paystack verify endpoint with their transaction reference. If it shows "success", your webhook was lost. Process the payment manually, then investigate why the webhook did not arrive. Check your webhook URL configuration and server logs.
- Should I refund an expired order if the payment later comes through?
- Not necessarily. If your webhook handler can detect late-arriving payments and revive expired orders, the customer gets their product automatically. Only refund if you cannot fulfill the order (sold out, offer expired, etc.).
- How do I handle pending transactions during deployments?
- Pending transactions survive deployments because their state is in the database, not in memory. The webhook will arrive after your deployment completes and be processed normally. Just make sure your deployment does not change the webhook URL or break the handler.
- Can I cancel a pending bank transfer on Paystack?
- You cannot cancel a pending bank transfer on the Paystack side. The temporary account number will expire automatically. If the customer transfers money after expiry, Paystack handles the reversal. On your side, just let the timeout expire and mark the order accordingly.
- Why do some bank transfers take hours?
- Nigerian inter-bank transfers go through NIBSS and can be delayed during high-traffic periods, weekends, or after bank working hours. Some banks batch transfers and process them in cycles rather than real-time. This is a banking infrastructure issue, not a Paystack issue.
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