McTaba Labs logo
Software EngineeringThe WhiteboardProjectsEventsMasterclassesWebinarsLearn
Book Call

500+ developers already in the community

Get answers, find accountability partners, and stay ahead

Join Discord →
McTaba Labs logo

Educating 1 million students in tech by 2030.

Programs

  • HighSchool Graduates Launchpad
  • AI & Business Automation
  • Corporate Training
  • McTaba Story & Mission
  • Full-Stack Curriculum
  • Course Pricing & Plans
  • Frequently Asked Questions

Resources

  • Join Discord Community
  • Tech Events in Nairobi
  • Masterclasses
  • Free Resources
  • Learn
  • Campus
  • Africa Tech Talent Report
  • Partner With Us
  • Start a Project
  • Our Work

Contact Us

  • 124 Manyani East Road, Lavington, Nairobi
  • +254727795603
  • Book a Discovery Call

Office Hours:

Mon-Fri: 9am - 2pm EAT

Sat: 10am - 12pm EAT

© 2027 McTaba Labs - A product of Edtech Industries. All rights reserved.

Privacy PolicyTerms of ServiceRefund Policy
  1. Learn
  2. Paystack for African Developers
  3. Building a Wallet Top-Up Flow on Paystack
Bonaventure OgetoBy Bonaventure Ogeto|Updated July 20, 2026

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.

In this article

  1. 1. Why Wallets Need Special Treatment
  2. 2. The Wallet Data Model
  3. 3. Initializing the Top-Up Payment
  4. 4. Webhook-Driven Wallet Crediting
  5. 5. Minimum Amounts and Validation
  6. 6. Handling the Callback Redirect
  7. 7. Edge Cases That Will Bite You
  8. 8. Showing Top-Up History to Users
  9. 9. Testing the Complete Flow
  10. 10. Build Payments That Work

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.

Get weekly developer tips

Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.

No spam. Unsubscribe anytime.

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.

Learn Paystack Integration

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

Not ready? Create Free Account

Keep reading

Accepting Payments with Paystack: Complete Guide

Everything you need to accept your first payment with Paystack: choosing between Initialize Transaction and the Charge API, handling amounts in kobo, attaching metadata, setting callback URLs, and designing transaction references that keep your system clean.

Read more

Paystack Metadata: Passing Custom Data Through a Transaction

Paystack metadata lets you attach arbitrary JSON data to a transaction that flows through the entire lifecycle: from initialization to verification to webhooks. This guide shows you what to put in metadata, how to structure it for different use cases, and the limits you need to stay within.

Read more

Paystack Transaction References: Design Patterns That Prevent Chaos

Your transaction reference is the single string that ties a Paystack payment to your internal records. A sloppy reference scheme leads to duplicate charges, mystery transactions, and 2 AM debugging sessions. This guide covers UUID, sequential, and composite patterns with code for each.

Read more

Reconciling Dedicated Virtual Account Deposits

Dedicated Virtual Accounts (DVAs) let each customer have a permanent bank account number linked to your Paystack integration. When a customer transfers money to their DVA, Paystack notifies you. But matching those deposits to the right customer, handling partial payments, and reconciling against your records requires careful engineering. This guide walks through the reconciliation workflow.

Read more

Paystack Webhook Retries: What to Expect and How to Cope

Paystack retries webhook deliveries when your endpoint fails to respond with 200. This guide explains the retry schedule, what counts as a failed delivery, how many retries Paystack sends, and the engineering patterns (idempotency, fast responses, monitoring) that keep retries from causing problems.

Read more

Explore related topics

M-Pesa & African Stack

M-Pesa Daraja API tutorials, USSD development, and WhatsApp Business API guides. The African Stack that global bootcamps do not teach.

Learn to Code in Kenya

Everything a Kenyan needs to know before writing their first line of code. Career paths, real costs, hardware requirements, and honest answers about what it takes.

Learning to Code

Roadmaps, language guides, and practical advice for learning to code and launching a programming career.

Breaking Into Tech

Practical, no-nonsense guides for anyone looking to start or change their career in tech. From choosing your first language to acing interviews.

The African Stack: Skills for This Market

The skills that make Kenyan developers hard to replace: M-Pesa, USSD, WhatsApp, fintech, and building for the African market.

Bonaventure Ogeto

Bonaventure Ogeto

Founder, McTaba Labs

Bonaventure is the founder of McTaba Labs, a coding bootcamp in Nairobi, Kenya. He has spent years building products for the African market and mentoring the next generation of developers across the continent.