Paystack Transaction Timed Out
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.
What "Transaction Timed Out" Actually Means
There are two different timeouts that get conflated under the same label:
- Channel timeout. The customer did not complete the payment action within the window Paystack allows. A card holder did not enter their OTP, a mobile money user did not approve the USSD prompt, or a bank transfer customer did not send money to the temporary account.
- Client-side timeout. Your server or frontend set a timeout on the HTTP request (for example, 30 seconds), and the response from Paystack did not arrive within that window. Paystack might still be processing the payment.
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.
Card 3DS Timeout
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:
- The bank's OTP page shows an expiry message
- Paystack receives a failure callback from the bank
- The transaction status changes to
failedwith gateway_response like "Transaction timed out" or "OTP expired" - Paystack sends a
charge.failedwebhook
The 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.
Mobile Money Timeout (M-Pesa, MTN MoMo)
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:
- M-Pesa STK Push: 60 seconds from when the prompt appears on the phone
- MTN MoMo: 60 to 120 seconds depending on the market
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 Timeout
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:
- The customer never initiates the bank transfer
- The customer transfers after the temporary account has expired
- The customer transfers the wrong amount
- The bank takes longer than expected to process the transfer
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."
What to Tell the Customer
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:
- "Payment failed" (you do not know that yet)
- "An error occurred" (vague, causes panic)
- "Transaction timed out, try again" (they might get double-charged if the first one goes through)
Say instead:
- "We are confirming your payment. This can take a few minutes. You will receive an email once we confirm."
- "Your payment is being processed. Please do not attempt another payment until you hear from us."
- "If you completed the payment, we will confirm it shortly. If not, you can try again in 10 minutes."
// 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.
The Webhook Can Still Arrive After Timeout
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:
- T+0s: Your server calls
/transaction/initialize - T+2s: Customer lands on the Paystack checkout page
- T+30s: Your frontend timeout fires, shows "pending" message
- T+45s: Customer enters OTP on their bank's page
- T+48s: Bank confirms the charge
- T+50s: Paystack sends
charge.successwebhook to your server
At 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);
}
}
});
Reconciliation: Catching What Webhooks Miss
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.
Setting Proper Timeout Values
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;
}
Verification: Confirm Your Timeout Handling Works
Test every timeout scenario before going live. Here is a checklist:
- Simulate a 3DS timeout. Start a card payment in test mode, land on the OTP page, and wait without entering the OTP. Confirm your system receives the
charge.failedwebhook and updates the order status. Confirm the customer sees a "pending" message, not a "failed" message. - Simulate a client-side timeout. Set your server timeout to 5 seconds and initiate a payment. The request may or may not complete in that window. Confirm your system saves the reference as "pending" and does not crash.
- Test the reconciliation job. Create a pending order with a known reference. Manually run the reconciliation job. Confirm it calls Paystack's verify endpoint and updates the order status based on the response.
- Test webhook after timeout. Create a pending order, then manually send a
charge.successwebhook event (using the Paystack dashboard or a test tool). Confirm the order is fulfilled even though the original request timed out. - Test customer messaging. Trigger a timeout in your browser and confirm the pending message appears. Confirm the reference number is displayed. Confirm there is no "try again" button that could cause a double charge.
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.
Key Takeaways
- ✓A Paystack transaction timeout does not mean the payment failed. The customer may have completed the payment after your frontend gave up waiting. Always check the webhook and verify the transaction before marking it as failed.
- ✓Card 3DS timeouts happen when the bank OTP page expires or the customer takes too long to enter the code. The typical window is 5 to 10 minutes. Paystack will still send a webhook with the final status.
- ✓Mobile money timeouts (M-Pesa, MTN MoMo) occur when the customer does not approve the USSD prompt within 60 to 120 seconds. The payment can still succeed if they approve late.
- ✓Bank transfer timeouts happen when the customer does not send money to the temporary account within the validity window, which is usually 30 minutes to a few hours depending on Paystack configuration.
- ✓Never show "Payment failed" to the customer on timeout. Show "Payment pending, we are confirming your payment" and check the status server-side before making a final determination.
- ✓Run a cron job or scheduled task that verifies all pending transactions older than 30 minutes by calling GET /transaction/verify/:reference. This catches cases where the webhook was missed.
- ✓Your client-side timeout and Paystack server-side timeout are different clocks. Even if your frontend times out at 60 seconds, Paystack may still be processing the payment upstream.
Frequently Asked Questions
- Does a timed-out Paystack transaction mean the customer was not charged?
- Not necessarily. A timeout on your end does not stop Paystack from processing the payment. The customer may have completed payment after your frontend gave up waiting. Always verify the transaction status through the webhook or the verification API before concluding the payment failed.
- How long should I wait before marking a timed-out transaction as failed?
- For card payments, 15 to 30 minutes is a safe waiting period. For mobile money, 5 minutes is usually enough since the USSD prompt expires within 2 minutes. For bank transfers, wait at least 24 hours because bank settlements can be slow. Use a reconciliation job to check pending transactions periodically rather than waiting with an open connection.
- Can I retry a transaction that timed out?
- Do not retry with the same reference. The original transaction may still be processing. If you need to allow the customer to try again, generate a new reference and create a new transaction. Check the status of the original transaction first to avoid double-charging.
- Why did my webhook arrive 5 minutes after the transaction timed out?
- Paystack sends webhooks after the payment processor (bank, mobile money provider) returns a final status. If the bank takes several minutes to process the OTP or the mobile money provider is slow to confirm, the webhook is delayed accordingly. This is normal. Your system should handle webhooks arriving at any time, not just within seconds of the transaction.
- How do I handle the case where both a webhook and a redirect callback arrive for the same transaction?
- Make your payment fulfillment idempotent. Before fulfilling an order, check if it has already been fulfilled. If the webhook arrives first and fulfills the order, the redirect callback should see the order is already paid and skip fulfillment. Use a database transaction with a status check to prevent race conditions.
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