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.
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.
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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
When the customer finishes the checkout flow and you do not yet have a final status, show them a processing page. This page should:
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.
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.
You cannot wait forever. Every pending transaction needs a timeout after which you take action. The timeout depends on the payment channel:
// 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.
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:
For bank transfers specifically, remind the customer of the details they need:
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.
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.
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