A timed-out Paystack transaction means the customer did not complete the payment within the allowed window, or your server did not receive a response in time. The transaction is not necessarily failed. For card 3DS, the bank OTP page may have expired. For mobile money and bank transfers, the customer simply did not confirm in time. Never mark the order as failed immediately. Instead, set the status to "pending", listen for the webhook, and run a verification check after a delay. The webhook can arrive minutes after the timeout.
There are two different timeouts that get conflated under the same label:
These are fundamentally different situations. A channel timeout means the customer ran out of time. A client-side timeout means you gave up waiting. In the second case, the payment might have succeeded on Paystack's end while your server was already showing an error page.
The safe approach for both: treat the transaction as "pending" until you get authoritative confirmation from Paystack, either through a webhook or a verification API call.
When a customer pays with a card that requires 3D Secure authentication, Paystack redirects them to their bank's OTP page. The bank gives the customer a limited window (typically 5 to 10 minutes) to enter the OTP sent to their phone.
If the customer does not enter the OTP in time:
failed with gateway_response like "Transaction timed out" or "OTP expired"charge.failed webhookThe gap between the bank timing out and Paystack sending you the webhook can be 30 seconds to a few minutes. During that gap, your customer is staring at your site wondering what happened.
// What the verification response looks like after a 3DS timeout
{
"status": true,
"message": "Verification successful",
"data": {
"status": "failed",
"reference": "txn_abc123",
"gateway_response": "Transaction timed out",
"channel": "card",
"metadata": {
"custom_fields": []
}
}
}
The outer "status": true means the API call succeeded. The inner "data.status": "failed" is the actual transaction result. Do not confuse the two.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
For mobile money payments, Paystack sends a charge request to the mobile money provider (Safaricom for M-Pesa, MTN for MoMo). The provider sends a USSD push to the customer's phone. The customer has a limited window to enter their PIN and approve.
Typical timeout windows:
If the customer does not approve in time, the mobile money provider sends a timeout response to Paystack. But there is a catch: the provider's timeout and Paystack's timeout are not instant. The customer might enter their PIN at second 59, the provider processes it at second 62, and from Paystack's perspective the transaction might still succeed.
This is why you should never mark mobile money transactions as failed the moment your client times out. Wait for the webhook. If no webhook arrives within 5 minutes, call the verification endpoint.
// Handling mobile money timeout in your payment flow
async function handleMobileMoneyCharge(reference: string) {
// After initiating the charge, poll or wait for webhook
// Do NOT set a 30-second timeout and mark as failed
const maxWaitMs = 5 * 60 * 1000; // 5 minutes
const startTime = Date.now();
// Set the order to pending immediately
await db.query(
'UPDATE orders SET payment_status = $1 WHERE paystack_reference = $2',
['pending', reference]
);
// The webhook handler will update the order when it arrives
// If no webhook arrives, the reconciliation job catches it
}
Bank transfer payments work differently from card and mobile money. Paystack generates a temporary bank account number, and the customer must transfer money to that account within a validity window. This window is typically 30 minutes but can be configured.
Timeout scenarios for bank transfers:
When the temporary account expires without receiving a matching deposit, Paystack marks the transaction as abandoned, not failed. An abandoned transaction can still be completed if the bank processes a transfer that was initiated before expiry but settled after.
// What an abandoned bank transfer looks like
{
"status": true,
"message": "Verification successful",
"data": {
"status": "abandoned",
"reference": "txn_bank_456",
"gateway_response": "The transaction was not completed",
"channel": "bank_transfer",
"paid_at": null
}
}
For bank transfers, communicate the deadline clearly to the customer. Show the account number, the amount, and a countdown timer. When the timer expires, show "This payment window has expired. Please try again." rather than "Payment failed."
The message you show the customer during a timeout matters more than most developers realise. A bad message causes support tickets. A good message gives the customer confidence and tells them what to do next.
Do not say:
Say instead:
// React component for timeout messaging
function PaymentPendingMessage({ reference }: { reference: string }) {
return (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<h3 className="text-lg font-semibold text-yellow-800">
Confirming your payment
</h3>
<p className="mt-2 text-yellow-700">
Your payment is being processed. This usually takes a few minutes.
We will send you a confirmation email once it is complete.
</p>
<p className="mt-2 text-yellow-700 font-medium">
Please do not make another payment attempt.
</p>
<p className="mt-4 text-sm text-yellow-600">
Reference: {reference}
</p>
</div>
);
}
Show the reference number so the customer can quote it if they contact support. This saves your support team minutes per ticket.
This is the most important thing to understand about timeouts. Your frontend or server timing out does not stop Paystack from processing the payment. The payment flow continues on Paystack's side regardless of whether your client is still listening.
Timeline of a typical timeout scenario:
/transaction/initializecharge.success webhook to your serverAt T+30s, your frontend gave up. But the payment succeeded at T+48s. If your webhook handler is working, your system gets the success notification at T+50s and can fulfill the order.
// Webhook handler that processes late-arriving events
app.post('/api/webhooks/paystack', async (req, res) => {
// Return 200 immediately so Paystack does not retry
res.status(200).json({ received: true });
const event = req.body;
if (event.event === 'charge.success') {
const reference = event.data.reference;
// Update order status even if the frontend already timed out
const order = await db.query(
'SELECT * FROM orders WHERE paystack_reference = $1',
[reference]
);
if (!order.rows.length) {
console.error('No order found for reference:', reference);
return;
}
if (order.rows[0].payment_status === 'paid') {
// Already processed, skip (idempotency)
return;
}
// Verify with Paystack before fulfilling
const verified = await verifyTransaction(reference);
if (verified.data.status === 'success') {
await db.query(
'UPDATE orders SET payment_status = $1, paid_at = NOW() WHERE paystack_reference = $2',
['paid', reference]
);
await fulfillOrder(order.rows[0].id);
await sendConfirmationEmail(order.rows[0].customer_email, reference);
}
}
});
Webhooks are not guaranteed to arrive. Your server might be down when Paystack sends them. The request might time out. Your handler might crash. You need a reconciliation process that catches these gaps.
The approach: run a scheduled job that finds all "pending" transactions older than a threshold (15 to 30 minutes) and verifies each one against the Paystack API.
// Reconciliation job: run every 15 minutes via cron
async function reconcilePendingTransactions() {
// Find orders that have been pending for more than 30 minutes
const cutoff = new Date(Date.now() - 30 * 60 * 1000);
const pendingOrders = await db.query(
`SELECT id, paystack_reference, created_at
FROM orders
WHERE payment_status = 'pending'
AND created_at < $1
ORDER BY created_at ASC
LIMIT 50`,
[cutoff]
);
for (const order of pendingOrders.rows) {
try {
const res = await fetch(
`https://api.paystack.co/transaction/verify/${order.paystack_reference}`,
{
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
},
}
);
const data = await res.json();
if (data.data?.status === 'success') {
await db.query(
'UPDATE orders SET payment_status = $1, paid_at = $2 WHERE id = $3',
['paid', data.data.paid_at, order.id]
);
await fulfillOrder(order.id);
console.log(`Reconciled order ${order.id}: payment succeeded`);
} else if (data.data?.status === 'failed') {
await db.query(
'UPDATE orders SET payment_status = $1 WHERE id = $2',
['failed', order.id]
);
console.log(`Reconciled order ${order.id}: payment failed`);
} else if (data.data?.status === 'abandoned') {
// Transaction was abandoned. Mark accordingly.
const ageHours = (Date.now() - new Date(order.created_at).getTime()) / (1000 * 60 * 60);
if (ageHours > 24) {
await db.query(
'UPDATE orders SET payment_status = $1 WHERE id = $2',
['expired', order.id]
);
}
// If less than 24 hours old, leave as pending. Bank transfers can settle late.
}
// Respect rate limits: small delay between verification calls
await new Promise(r => setTimeout(r, 200));
} catch (err) {
console.error(`Failed to reconcile order ${order.id}:`, err);
}
}
}
// Schedule: every 15 minutes
// cron: */15 * * * * node reconcile.js
The 200ms delay between API calls prevents you from hitting Paystack's rate limit when reconciling many transactions at once. Process the oldest pending transactions first so nothing gets stuck indefinitely.
Your client-side and server-side timeouts should be set with payment processing times in mind, not generic HTTP timeout defaults.
Recommended timeouts by channel:
| Channel | Customer window | Your server timeout | Frontend UX timeout |
|---|---|---|---|
| Card (no 3DS) | N/A | 30s | 30s |
| Card (3DS) | 5-10 min | Do not poll | Show redirect, do not time out |
| Mobile money | 60-120s | 120s | 120s with countdown |
| Bank transfer | 30 min+ | N/A (async) | Show account details with timer |
| USSD | 60-120s | 120s | 120s with instructions |
For card payments with 3DS, the customer is redirected to their bank's page. Your server should not be holding an open connection waiting for this. Use the redirect flow and rely on callbacks and webhooks.
// Setting appropriate timeouts for a Paystack API call
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 seconds
try {
const res = await fetch('https://api.paystack.co/transaction/initialize', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, amount, reference }),
signal: controller.signal,
});
clearTimeout(timeoutId);
const data = await res.json();
return data;
} catch (err: any) {
clearTimeout(timeoutId);
if (err.name === 'AbortError') {
// Our timeout fired, but Paystack may still process this
// Save reference as pending and let webhook/reconciliation handle it
await markTransactionPending(reference);
return { status: 'pending', reference };
}
throw err;
}
Test every timeout scenario before going live. Here is a checklist:
charge.failed webhook and updates the order status. Confirm the customer sees a "pending" message, not a "failed" message.charge.success webhook event (using the Paystack dashboard or a test tool). Confirm the order is fulfilled even though the original request timed out.For simulating network timeouts in tests, use a tool like nock (Node.js) to delay the API response beyond your timeout threshold. For webhook testing, see Testing Paystack Webhooks Locally with Ngrok.
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