Bonaventure OgetoBy Bonaventure Ogeto|

Building a Wallet Top-Up Flow on Paystack

To build a wallet top-up on Paystack, initialize a transaction with the top-up amount and the user ID in metadata. When the charge.success webhook fires, verify the transaction, then credit the wallet in a database transaction that also records the Paystack reference. Use the reference as an idempotency key to prevent double-crediting.

Why Wallets Need Special Treatment

A wallet top-up is different from a normal purchase. When someone buys a pair of shoes, you fulfill the order once. When someone loads money into an in-app wallet, you are creating balance that the user will spend later. Get it wrong and real money goes missing or appears out of thin air.

The three failure modes that matter most:

  • Double-crediting. The webhook fires twice (Paystack retries on timeout), and you add the balance twice. The user gets free money.
  • Missing credits. The payment succeeds on Paystack's side, but your server crashes before recording the credit. The user paid and got nothing.
  • Amount mismatch. The frontend says "top up 5,000 KES" but the actual Paystack transaction was initialized for 500 KES. Your server trusts the frontend and credits 5,000.

Every design decision in this guide exists to prevent one of these three problems. The key principle is simple: the Paystack webhook is the only trigger for crediting a wallet, and every credit operation must be idempotent.

The Wallet Data Model

Before writing any Paystack code, get your database schema right. You need three things: a wallet table, a ledger table, and a foreign key to the Paystack reference.

-- Wallets table
CREATE TABLE wallets (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id),
  balance_cents BIGINT NOT NULL DEFAULT 0,
  currency VARCHAR(3) NOT NULL DEFAULT 'KES',
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- Wallet ledger (every credit and debit gets a row)
CREATE TABLE wallet_transactions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  wallet_id UUID NOT NULL REFERENCES wallets(id),
  type VARCHAR(10) NOT NULL CHECK (type IN ('credit', 'debit')),
  amount_cents BIGINT NOT NULL,
  balance_after_cents BIGINT NOT NULL,
  paystack_reference VARCHAR(100) UNIQUE,
  description TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- The UNIQUE constraint on paystack_reference prevents double-crediting

The paystack_reference column has a UNIQUE constraint. If your code tries to insert a second row with the same reference, the database rejects it. This is your last line of defense against duplicate credits, and it works even if your application-level check has a race condition.

Store balances in the smallest currency unit (cents for KES, kobo for NGN, pesewas for GHS). This matches what Paystack returns and eliminates floating-point issues entirely.

Initializing the Top-Up Payment

When a user taps "Top Up" in your app, your backend initializes a Paystack transaction. The critical piece is passing the wallet ID and user ID in the metadata so you know which wallet to credit when the webhook arrives.

// POST /api/wallet/topup
app.post('/api/wallet/topup', async (req, res) => {
  var userId = req.user.id; // From your auth middleware
  var amountInShillings = req.body.amount;

  // Enforce minimum top-up
  if (amountInShillings < 100) {
    return res.status(400).json({ error: 'Minimum top-up is 100 KES' });
  }

  // Enforce maximum top-up
  if (amountInShillings > 100000) {
    return res.status(400).json({ error: 'Maximum top-up is 100,000 KES' });
  }

  var wallet = await db.query(
    'SELECT id, currency FROM wallets WHERE user_id = $1',
    [userId]
  );

  if (!wallet.rows.length) {
    return res.status(404).json({ error: 'No wallet found' });
  }

  var reference = 'topup_' + wallet.rows[0].id + '_' + Date.now();

  var response = 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: req.user.email,
      amount: amountInShillings * 100, // Convert to cents
      currency: 'KES',
      reference: reference,
      callback_url: process.env.APP_URL + '/wallet/topup/complete',
      metadata: {
        wallet_id: wallet.rows[0].id,
        user_id: userId,
        top_up_amount_cents: amountInShillings * 100,
        custom_fields: [
          {
            display_name: 'Top-Up Amount',
            variable_name: 'topup_amount',
            value: amountInShillings + ' KES',
          },
        ],
      },
    }),
  });

  var data = await response.json();

  if (data.status) {
    res.json({
      authorization_url: data.data.authorization_url,
      reference: data.data.reference,
    });
  } else {
    res.status(400).json({ error: data.message });
  }
});

A few things to notice:

  • The reference includes the wallet ID and a timestamp. This makes it unique per top-up attempt and easy to trace when debugging.
  • The minimum and maximum checks happen server-side. Frontend validation is a convenience; server validation is the rule.
  • The metadata carries the wallet ID, user ID, and the expected amount. The webhook handler will use these.

Webhook-Driven Wallet Crediting

The wallet credit happens in your webhook handler, not in the callback redirect. The redirect is for showing the user a "top-up successful" page. The webhook is for actually moving money into the wallet.

Here is the full webhook handler for wallet top-ups:

var crypto = require('crypto');

app.post('/api/webhooks/paystack', async (req, res) => {
  // 1. Verify the webhook signature
  var hash = crypto
    .createHmac('sha512', process.env.PAYSTACK_SECRET_KEY)
    .update(JSON.stringify(req.body))
    .digest('hex');

  if (hash !== req.headers['x-paystack-signature']) {
    return res.status(401).send('Invalid signature');
  }

  // 2. Respond immediately (Paystack expects 200 within seconds)
  res.status(200).send('OK');

  // 3. Process the event asynchronously
  var event = req.body;

  if (event.event === 'charge.success') {
    await handleChargeSuccess(event.data);
  }
});

async function handleChargeSuccess(data) {
  var reference = data.reference;
  var metadata = data.metadata || {};

  // Only process wallet top-ups
  if (!reference.startsWith('topup_')) {
    return;
  }

  var walletId = metadata.wallet_id;
  if (!walletId) {
    console.error('No wallet_id in metadata for reference: ' + reference);
    return;
  }

  // 4. Verify the transaction with Paystack
  var verifyResponse = await fetch(
    'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
    {
      headers: {
        Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      },
    }
  );

  var verifyData = await verifyResponse.json();

  if (!verifyData.status || verifyData.data.status !== 'success') {
    console.error('Verification failed for reference: ' + reference);
    return;
  }

  var amountCents = verifyData.data.amount;
  var currency = verifyData.data.currency;

  // 5. Credit the wallet in a database transaction
  var client = await db.connect();
  try {
    await client.query('BEGIN');

    // Check if this reference was already processed
    var existing = await client.query(
      'SELECT id FROM wallet_transactions WHERE paystack_reference = $1',
      [reference]
    );

    if (existing.rows.length > 0) {
      await client.query('ROLLBACK');
      console.log('Already processed: ' + reference);
      return;
    }

    // Update wallet balance
    var walletResult = await client.query(
      'UPDATE wallets SET balance_cents = balance_cents + $1, updated_at = NOW() WHERE id = $2 AND currency = $3 RETURNING balance_cents',
      [amountCents, walletId, currency]
    );

    if (!walletResult.rows.length) {
      await client.query('ROLLBACK');
      console.error('Wallet not found or currency mismatch: ' + walletId);
      return;
    }

    // Insert ledger entry
    await client.query(
      'INSERT INTO wallet_transactions (wallet_id, type, amount_cents, balance_after_cents, paystack_reference, description) VALUES ($1, $2, $3, $4, $5, $6)',
      [walletId, 'credit', amountCents, walletResult.rows[0].balance_cents, reference, 'Wallet top-up via Paystack']
    );

    await client.query('COMMIT');
    console.log('Wallet credited: ' + walletId + ' amount: ' + amountCents);
  } catch (err) {
    await client.query('ROLLBACK');
    // If the error is a unique constraint violation on paystack_reference,
    // this is a duplicate and is safe to ignore
    if (err.code === '23505') {
      console.log('Duplicate credit attempt caught by DB constraint: ' + reference);
    } else {
      console.error('Failed to credit wallet:', err);
      throw err; // Re-throw so the webhook returns an error and Paystack retries
    }
  } finally {
    client.release();
  }
}

The double-credit prevention has two layers. First, the application checks whether the reference already exists in the ledger. Second, the UNIQUE database constraint on paystack_reference catches any race condition where two webhook deliveries hit the server at the same time and both pass the application check.

Minimum Amounts and Validation

Paystack enforces minimum transaction amounts per currency. Your business will likely have its own minimums on top of those. Handle both.

Paystack's minimum amounts vary by currency and can change. Rather than hard-coding them, structure your validation to catch the error gracefully:

var BUSINESS_MINIMUMS = {
  NGN: 50000,    // 500 NGN in kobo
  GHS: 100,      // 1 GHS in pesewas
  KES: 10000,    // 100 KES in cents
  ZAR: 500,      // 5 ZAR in cents
  USD: 100,      // 1 USD in cents
};

function validateTopUpAmount(amountCents, currency) {
  var businessMin = BUSINESS_MINIMUMS[currency];
  if (!businessMin) {
    return { valid: false, error: 'Unsupported currency: ' + currency };
  }

  if (amountCents < businessMin) {
    return {
      valid: false,
      error: 'Minimum top-up for ' + currency + ' is ' + (businessMin / 100),
    };
  }

  if (amountCents > 10000000) { // 100,000 in main unit
    return { valid: false, error: 'Maximum top-up exceeded' };
  }

  if (!Number.isInteger(amountCents) || amountCents <= 0) {
    return { valid: false, error: 'Amount must be a positive integer' };
  }

  return { valid: true };
}

If Paystack rejects the amount (because it is below their minimum for that currency), the initialize call returns an error message. Parse that error and show the user a clear message rather than a generic "payment failed" screen.

Maximum amounts matter too. You probably do not want a user loading 10 million KES into an app wallet in a single transaction. Set sensible limits based on your business context and regulatory requirements.

Handling the Callback Redirect

After the user pays on Paystack's checkout page, they get redirected to your callback_url. This redirect is for user experience only. The wallet was already credited (or will be shortly) by the webhook handler.

// GET /wallet/topup/complete?reference=topup_abc123_1721472000000
app.get('/wallet/topup/complete', async (req, res) => {
  var reference = req.query.reference;

  if (!reference) {
    return res.redirect('/wallet?error=missing_reference');
  }

  // Check if the webhook already credited the wallet
  var ledgerEntry = await db.query(
    'SELECT amount_cents FROM wallet_transactions WHERE paystack_reference = $1',
    [reference]
  );

  if (ledgerEntry.rows.length > 0) {
    // Already credited by webhook
    var amountDisplay = ledgerEntry.rows[0].amount_cents / 100;
    return res.redirect('/wallet?success=true&amount=' + amountDisplay);
  }

  // Webhook has not arrived yet. Verify and show status.
  var verifyResponse = await fetch(
    'https://api.paystack.co/transaction/verify/' + encodeURIComponent(reference),
    {
      headers: {
        Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      },
    }
  );

  var data = await verifyResponse.json();

  if (data.data && data.data.status === 'success') {
    // Payment succeeded but webhook has not processed yet
    // Show a "processing" message. Do NOT credit here.
    return res.redirect('/wallet?status=processing');
  }

  return res.redirect('/wallet?error=payment_failed');
});

Notice that the callback handler does not credit the wallet. It only checks whether the webhook already did, and shows the appropriate page. If the webhook has not arrived yet but verification shows success, show a "processing" message. The webhook will credit the wallet shortly.

Some developers are tempted to credit the wallet in both the webhook and the callback (whichever fires first). This can work if your idempotency logic is solid, but it adds complexity. Keeping the webhook as the single source of truth is simpler and easier to debug.

Edge Cases That Will Bite You

Webhook arrives before the redirect. This is common. The user is still on Paystack's checkout page (or their browser is loading your callback URL), but the webhook has already fired and credited the wallet. When the callback handler runs, it finds the ledger entry and shows the success page. No problem if you built it right.

Webhook never arrives. Paystack retries webhooks for up to 72 hours, but in rare cases (your server was down the entire time, or your webhook URL was misconfigured), the webhook may never arrive. Build a reconciliation job that runs every hour and checks for "pending" top-up transactions:

// Reconciliation: find paid transactions that were never credited
async function reconcileTopUps() {
  // Fetch recent transactions from Paystack
  var response = await fetch(
    'https://api.paystack.co/transaction?status=success&from=' + getYesterdayISO(),
    {
      headers: {
        Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
      },
    }
  );

  var data = await response.json();
  var transactions = data.data || [];

  for (var i = 0; i < transactions.length; i++) {
    var txn = transactions[i];
    if (!txn.reference.startsWith('topup_')) continue;

    var existing = await db.query(
      'SELECT id FROM wallet_transactions WHERE paystack_reference = $1',
      [txn.reference]
    );

    if (existing.rows.length === 0) {
      console.log('Missing credit found: ' + txn.reference);
      // Call the same handleChargeSuccess function
      await handleChargeSuccess(txn);
    }
  }
}

User closes the browser mid-payment. The transaction sits in "abandoned" or "pending" state on Paystack. If the user actually completed the charge before closing (entered OTP, for example), the payment may still complete and the webhook will fire. If they did not complete it, the transaction expires and no money moves.

Currency mismatch. Your wallet is in KES but the Paystack transaction settled in USD. This should not happen if you pass the currency when initializing, but verify it in the webhook handler anyway. The line WHERE id = $2 AND currency = $3 in the wallet update query catches this.

Showing Top-Up History to Users

The wallet ledger table gives you everything you need to show the user their top-up history. Each row has the amount, the timestamp, and the Paystack reference they can use if they need to dispute a charge.

// GET /api/wallet/history
app.get('/api/wallet/history', async (req, res) => {
  var userId = req.user.id;

  var wallet = await db.query(
    'SELECT id, balance_cents, currency FROM wallets WHERE user_id = $1',
    [userId]
  );

  if (!wallet.rows.length) {
    return res.status(404).json({ error: 'No wallet found' });
  }

  var transactions = await db.query(
    'SELECT type, amount_cents, balance_after_cents, description, created_at FROM wallet_transactions WHERE wallet_id = $1 ORDER BY created_at DESC LIMIT 50',
    [wallet.rows[0].id]
  );

  res.json({
    balance: wallet.rows[0].balance_cents / 100,
    currency: wallet.rows[0].currency,
    transactions: transactions.rows.map(function(row) {
      return {
        type: row.type,
        amount: row.amount_cents / 100,
        balanceAfter: row.balance_after_cents / 100,
        description: row.description,
        date: row.created_at,
      };
    }),
  });
});

Keep the balance display in the main currency unit (KES, not cents) for users. Keep the internal storage in the smallest unit for precision. The conversion is always a division by 100.

Testing the Complete Flow

Use Paystack's test keys (starting with sk_test_) to test the entire flow without moving real money. Here is a testing checklist:

  1. Happy path. Initialize a top-up, complete payment on the Paystack test checkout, confirm that the webhook fires and the wallet balance increases.
  2. Double webhook. Send the same webhook payload twice (manually POST it to your webhook endpoint). Confirm the wallet is credited only once.
  3. Amount validation. Try initializing a top-up below your minimum. Try one above your maximum. Confirm both are rejected.
  4. Missing metadata. Send a charge.success webhook without a wallet_id in metadata. Confirm your handler logs the error and does not crash.
  5. Concurrent requests. Send two identical webhook payloads at the same time (from two terminal windows). Confirm only one credit goes through.
  6. Callback before webhook. Hit your callback URL with a valid reference before the webhook fires. Confirm the page shows "processing" and the wallet is not credited.

For local webhook testing, use a tunneling tool to expose your local server. See our guide on testing Paystack webhooks locally for setup instructions.

Build Payments That Work

Wallet top-ups are one of the trickier Paystack patterns because the stakes are higher. Every bug becomes a support ticket about missing money or phantom balance.

The McTaba Paystack guides cover the full integration path, from your first Initialize Transaction call to production-grade webhook handling. If you want to go deeper on any concept mentioned here, start with the accepting payments complete guide.

Create a free McTaba account

Key Takeaways

  • Wallet crediting should be driven by webhooks, not by the redirect callback. The webhook is the reliable signal that money actually moved.
  • Store the Paystack transaction reference alongside every wallet credit. Check for it before crediting to prevent double-credits when webhooks retry.
  • Wrap the wallet credit and the ledger insert in a single database transaction. If either fails, neither commits.
  • Enforce minimum top-up amounts on your server, not just in the frontend. Paystack has its own minimums per currency, but your business logic may require higher ones.
  • Use metadata to pass the user ID and wallet ID through the Paystack transaction. This links the payment to the correct wallet without relying on session state.
  • Always verify the transaction amount server-side before crediting. A tampered frontend request could send a smaller amount than what the user sees.

Frequently Asked Questions

Should I credit the wallet from the webhook or the callback redirect?
Credit from the webhook. The webhook is the reliable signal that payment actually completed. The callback redirect can fail (user closes browser, network drops), so treating it as the crediting trigger means some users will pay but never get credited. The callback should only show the user a success or processing page.
How do I prevent double-crediting when Paystack retries a webhook?
Use two layers of protection. First, check your database for an existing ledger entry with the same Paystack reference before crediting. Second, add a UNIQUE constraint on the paystack_reference column in your wallet_transactions table. The database constraint catches race conditions that the application check can miss.
What is the minimum top-up amount for Paystack?
Paystack enforces minimum transaction amounts per currency, which can change. Check the Paystack documentation or test with small amounts to find the current minimums. Your business should also enforce its own minimums that make sense for your product. Validate on the server, not just in the frontend.
Can I use Dedicated Virtual Accounts for wallet top-ups instead?
Yes. Dedicated Virtual Accounts (DVAs) let you assign a permanent bank account number to each user. When they transfer money to that account, Paystack sends a webhook and you credit their wallet. This removes the checkout step entirely. See the guide on dedicated virtual accounts for implementation details.
What happens if my server is down when the top-up webhook arrives?
Paystack retries failed webhook deliveries with increasing intervals over up to 72 hours. If your server comes back online within that window, the webhook will eventually arrive and the wallet will be credited. As a safety net, build a reconciliation job that checks Paystack for successful transactions that do not have matching wallet credits.

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