Offline-tolerant payment flows use payment channels that do not require constant internet (USSD, bank transfer), implement local queuing for payment confirmations, handle webhook retries gracefully, and give customers clear fallback paths when connectivity drops. The goal is not to make Paystack work offline but to design your application so that intermittent connectivity does not result in lost payments or confused customers.
Network conditions in many African markets are different from what Silicon Valley engineers assume. Your payment flow needs to account for these realities:
The goal is not to make payments work with zero connectivity. Payments require communication between parties. The goal is to make your payment flow resilient to gaps and delays in that communication.
Different Paystack payment channels have different connectivity requirements:
USSD (best for poor connectivity)
The customer dials a USSD code (like *737*amount*ref#) on their phone. The USSD session runs over the cellular network, not the internet. The customer does not need data or WiFi. They just need cell signal. After the customer completes the USSD session, their bank notifies Paystack, and Paystack sends you a webhook.
Engineering consideration: your server still needs to be online to receive the webhook. But the customer-side payment is fully offline.
Bank transfer (good for intermittent connectivity)
You generate a virtual account number during checkout. The customer receives this account number and can transfer money at any time using their banking app. The transfer does not have to happen during your checkout session. The customer can write down the account number, close your site, and transfer later when they have better connectivity.
Engineering consideration: the account number expires after a set period (you configure this). Set a generous expiry window for markets with poor connectivity.
Card payments (requires continuous connectivity)
Card payments need the customer to have an active internet connection throughout checkout. They load the Paystack page, enter card details, wait for OTP, and complete 3DS verification. Any connectivity drop during these steps can abort the payment.
For offline-tolerant flows, make USSD or bank transfer the default payment method and offer card as an alternative.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Here is how to set up a USSD payment flow that works even when your customer has no internet:
// Initialize a USSD charge
app.post('/api/pay/ussd', async (req, res) => {
const { email, amount, orderId, bankCode } = req.body;
try {
const response = await fetch('https://api.paystack.co/charge', {
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
amount: amount * 100,
ussd: { type: bankCode }, // e.g., '737' for GTBank
metadata: { order_id: orderId },
}),
});
const data = await response.json();
// Store the pending payment in your database
await db.query(
'INSERT INTO pending_payments (order_id, reference, channel, status, created_at) VALUES ($1, $2, $3, $4, $5)',
[orderId, data.data.reference, 'ussd', 'pending', new Date()]
);
// Return the USSD code for the customer to dial
res.json({
ussdCode: data.data.display_text,
reference: data.data.reference,
message: 'Dial the USSD code below on your phone. No internet needed.',
});
} catch (error) {
res.status(500).json({
error: 'Could not generate USSD code. Please try again.',
});
}
});
On the frontend, display the USSD code prominently. Tell the customer they can close the browser and dial the code on their phone. They do not need to keep your page open. After the customer pays, your webhook handler picks up the notification and fulfills the order.
If the customer cannot reach your server in the first place (no internet at all), they cannot even start the payment. In that case, consider displaying pre-generated USSD instructions on printed materials (receipts, posters) or via SMS.
Webhooks are Paystack's primary way of notifying you about payment events. But webhooks can fail. Your server might be down when the webhook arrives, or a network hiccup might prevent delivery. You need a fallback.
// Polling job for pending payments (run every 5 minutes)
async function checkPendingPayments() {
const pending = await db.query(
'SELECT reference, order_id, created_at FROM pending_payments WHERE status = $1 AND created_at > $2',
['pending', new Date(Date.now() - 24 * 60 * 60 * 1000)] // Last 24 hours
);
for (const payment of pending.rows) {
try {
const response = await fetch(
'https://api.paystack.co/transaction/verify/' + payment.reference,
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
}
);
const data = await response.json();
if (data.data.status === 'success') {
// Payment was successful but webhook was missed
await db.query(
'UPDATE pending_payments SET status = $1 WHERE reference = $2',
['verified', payment.reference]
);
await fulfillOrder(payment.order_id);
console.log('Recovered missed payment: ' + payment.reference);
} else if (data.data.status === 'failed') {
await db.query(
'UPDATE pending_payments SET status = $1 WHERE reference = $2',
['failed', payment.reference]
);
}
// If status is still "pending" or "abandoned", keep checking
} catch (error) {
// Could not reach Paystack. Will try again next cycle.
console.log('Verify failed for ' + payment.reference + ': ' + error.message);
}
}
}
// Run every 5 minutes
setInterval(checkPendingPayments, 5 * 60 * 1000);
This polling job catches payments that succeeded on Paystack's side but never triggered your webhook handler. It is your safety net. Run it frequently enough to catch payments quickly (every 2 to 5 minutes) but not so frequently that you flood Paystack with verify calls.
When your server tries to verify a transaction but cannot reach Paystack (network timeout), do not give up. Queue the verification and retry with exponential backoff.
async function verifyWithRetry(reference, maxRetries) {
let attempt = 0;
const retries = maxRetries || 5;
while (attempt < retries) {
try {
const response = await fetch(
'https://api.paystack.co/transaction/verify/' + reference,
{
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
},
signal: AbortSignal.timeout(10000), // 10 second timeout
}
);
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
return await response.json();
} catch (error) {
attempt++;
if (attempt >= retries) {
throw new Error('Verification failed after ' + retries + ' attempts: ' + error.message);
}
// Exponential backoff: 2s, 4s, 8s, 16s, 32s
const delay = Math.pow(2, attempt) * 1000;
console.log('Verify attempt ' + attempt + ' failed. Retrying in ' + (delay / 1000) + 's');
await new Promise(function(resolve) { setTimeout(resolve, delay); });
}
}
}
// Usage in your webhook handler or callback
app.get('/payment/callback', async (req, res) => {
const reference = req.query.reference;
try {
const data = await verifyWithRetry(reference, 5);
if (data.data.status === 'success') {
await fulfillOrder(reference);
res.redirect('/order/success');
} else {
res.redirect('/order/failed');
}
} catch (error) {
// All retries failed. Show a pending page.
res.redirect('/order/pending?ref=' + reference);
}
});
The pending page is important. When you cannot verify a payment after multiple retries, do not show "Payment failed." The payment may have actually succeeded. Show "We are confirming your payment. You will receive a confirmation email shortly." Then let your background polling job eventually catch the payment and send the confirmation.
Technical resilience is only half the solution. The other half is designing the customer experience so that poor connectivity does not feel broken.
// Simple payment status check endpoint
app.get('/api/payment-status/:reference', async (req, res) => {
const order = await db.query(
'SELECT status FROM orders WHERE paystack_reference = $1',
[req.params.reference]
);
if (order.rows.length === 0) {
return res.json({ status: 'not_found', message: 'No payment found with this reference.' });
}
const status = order.rows[0].status;
const messages = {
pending: 'Your payment is being processed. Please check back in a few minutes.',
paid: 'Your payment was received successfully.',
failed: 'Your payment was not successful. Please try again.',
};
res.json({ status, message: messages[status] || 'Unknown status.' });
});
Your server also needs to handle connectivity issues with Paystack's API. Here are patterns for server-side resilience:
For the full accept payments workflow, see the complete accept payments 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