Bonaventure OgetoBy Bonaventure Ogeto|

Offline-Tolerant Payment Flows with Paystack

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.

The Connectivity Problem in African Markets

Network conditions in many African markets are different from what Silicon Valley engineers assume. Your payment flow needs to account for these realities:

  • Intermittent connectivity. A customer might have a stable connection when they start checkout and lose it 30 seconds later. They might regain it in a minute or in an hour.
  • Slow connections. 2G and 3G connections are still common in rural areas and during peak hours. A Paystack checkout page that loads 500KB of JavaScript may time out before the customer can interact with it.
  • Data cost awareness. Customers on limited data plans may avoid payment methods that require loading heavy pages. USSD uses no data. Bank transfers use minimal data (just the banking app).
  • Server-side instability. Your server might lose connection to Paystack's API briefly. Network routes between African data centers and cloud providers sometimes hiccup.

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.

Choosing the Right Payment Channels

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.

Implementing USSD Payments for Offline Scenarios

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.

Handling Missed Webhooks

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.

Queuing Verification Requests with Retry Logic

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.

Designing the Customer Experience for Bad Networks

Technical resilience is only half the solution. The other half is designing the customer experience so that poor connectivity does not feel broken.

  • Show a persistent reference number. After initiating payment, display the transaction reference prominently. Tell the customer: "Your payment reference is ABC123. Keep this number. You can contact us with it if anything goes wrong."
  • Send an SMS confirmation. If the customer's email might not load (poor data), send an SMS confirmation when payment is verified. SMS works on any phone, even without internet.
  • Offer multiple payment channels. Show USSD and bank transfer alongside card payment. Let the customer pick the method that works with their current connectivity.
  • Use lightweight pages. Keep your payment page under 100KB. Remove unnecessary images, fonts, and JavaScript. Every kilobyte matters on a 2G connection.
  • Provide a "check payment status" page. Give customers a URL or page where they can enter their reference number and check whether their payment was received. This reduces support inquiries from customers who paid but never saw a confirmation.
// 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.' });
});

Server-Side Network Resilience

Your server also needs to handle connectivity issues with Paystack's API. Here are patterns for server-side resilience:

  • Set request timeouts. Every API call to Paystack should have a timeout (10 to 15 seconds). Without a timeout, a hung connection blocks your request handler indefinitely.
  • Use circuit breaker pattern. If Paystack's API is consistently failing (multiple timeouts in a row), stop making calls for a short period and return a "try again later" response. This prevents your server from stacking up blocked requests during an outage.
  • Log every API failure. Track when and how often Paystack API calls fail. This data helps you distinguish between your network issues and Paystack-side issues.
  • Have a retry queue. For critical operations (like verifying a payment), do not give up after one failure. Queue the operation and retry. For non-critical operations (like fetching settlement data), log the failure and try again on the next scheduled run.

For the full accept payments workflow, see the complete accept payments guide.

Stay Up to Date

Network conditions and payment channels evolve across African markets. We update these guides as Paystack adds new offline-friendly features.

Join the McTaba newsletter for new Paystack engineering guides.

Sign up for the McTaba newsletter

Key Takeaways

  • USSD payments work without internet on the customer side. The customer dials a code on their phone, and the USSD session runs over the cellular network. This makes USSD the most offline-friendly Paystack payment channel.
  • Bank transfer payments are semi-offline. The customer receives an account number, then uses their banking app (which needs internet) to make the transfer. But they can do the transfer at any time before the account expires, not necessarily during your checkout session.
  • Card payments require a continuous internet connection for the customer during checkout. If connectivity is unreliable, steer customers toward USSD or bank transfer instead.
  • Your server needs to handle missed webhooks. If your server is offline when Paystack sends a webhook, you miss the payment notification. Implement a polling fallback that checks pending transaction statuses periodically.
  • Queue payment verification requests locally. If your server cannot reach Paystack to verify a transaction (network timeout), queue the verification and retry with exponential backoff.
  • Show the customer a clear pending state. When connectivity is bad, the worst experience is uncertainty. Show "We are checking your payment status" and keep trying in the background.

Frequently Asked Questions

Can customers pay through Paystack with no internet at all?
USSD payments do not require internet on the customer side. The customer dials a code on their phone using the cellular network. However, your server still needs internet to receive the webhook notification from Paystack. Bank transfer also works semi-offline: the customer gets the account number on your page (requires internet briefly) but can complete the transfer later via their banking app or USSD banking.
What happens if my server is offline when Paystack sends a webhook?
Paystack retries failed webhook deliveries. If your server returns a non-200 response or times out, Paystack attempts redelivery. However, you should not rely solely on retries. Implement a polling job that periodically checks pending transaction statuses with the Verify endpoint. This catches payments that succeeded but whose webhooks were never delivered.
How long should I keep polling for pending payments?
For USSD and card payments, poll for up to 24 hours. USSD sessions typically resolve within minutes, but bank-side processing can add delays. For bank transfers, poll until the virtual account expires (you set the expiry when creating the charge). After the expiry window, mark the payment as expired and stop polling.
Should I show a loading spinner while waiting for payment confirmation?
For USSD and bank transfer payments, no. The customer completes the payment on a separate device (their phone or banking app). Show the payment instructions (USSD code or account number) and a "We will notify you when your payment is confirmed" message. For card payments where the customer is actively in the checkout flow, a loading spinner is appropriate but should time out after 30 seconds with a "still processing" message.

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