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.
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:
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.
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.
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 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.
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.
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.
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.
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.
Use Paystack's test keys (starting with sk_test_) to test the entire flow without moving real money. Here is a testing checklist:
For local webhook testing, use a tunneling tool to expose your local server. See our guide on testing Paystack webhooks locally for setup instructions.
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