A vendor payout portal gives sellers visibility into their earnings and payout status. Build it with four components: an earnings dashboard (total earned, pending settlement, paid out), a payout history table (each payout with status, amount, date), bank detail management (view and update their payment details), and optionally, a withdrawal request feature for on-demand payouts.
Vendors on your marketplace have one core question: where is my money? A good payout portal answers that question before they have to ask it.
The portal needs four sections:
Everything in this portal reads from your database, not from Paystack directly. Your webhook handlers keep payout statuses in sync, and your earnings calculation logic computes the amounts from order and transaction data. Paystack is the payment rail; your database is the display layer.
async function getVendorEarnings(vendorId, periodStart, periodEnd) {
// Orders completed in the period
var orders = await db.orders.findCompletedByVendor(
vendorId, periodStart, periodEnd
);
var grossEarnings = 0;
var platformCommission = 0;
for (var i = 0; i < orders.length; i++) {
grossEarnings += orders[i].vendorAmount;
platformCommission += orders[i].platformCommission;
}
// Payouts completed in the period
var payouts = await db.payouts.findCompletedByVendor(
vendorId, periodStart, periodEnd
);
var totalPaidOut = 0;
for (var j = 0; j < payouts.length; j++) {
totalPaidOut += payouts[j].amountMinorUnit;
}
// Payouts pending
var pendingPayouts = await db.payouts.findPendingByVendor(vendorId);
var totalPending = 0;
for (var k = 0; k < pendingPayouts.length; k++) {
totalPending += pendingPayouts[k].amountMinorUnit;
}
return {
periodStart: periodStart,
periodEnd: periodEnd,
grossEarnings: grossEarnings,
platformCommission: platformCommission,
netEarnings: grossEarnings - platformCommission,
paidOut: totalPaidOut,
pendingPayout: totalPending,
availableForWithdrawal: (grossEarnings - platformCommission) - totalPaidOut - totalPending,
orderCount: orders.length,
};
}
The availableForWithdrawal field is what the vendor cares about most. It shows how much money has accumulated from their sales that has not yet been paid out or is not already in a pending payout. This is the amount they could withdraw if you offer on-demand payouts.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
async function getPayoutHistory(vendorId, page, perPage) {
var payouts = await db.payouts.findByVendor(vendorId, {
page: page || 1,
perPage: perPage || 20,
orderBy: 'created_at',
orderDirection: 'DESC',
});
return payouts.map(function(p) {
return {
id: p.id,
amount: p.amountMinorUnit / 100,
currency: p.currency,
status: mapStatusForDisplay(p.status),
statusColor: getStatusColor(p.status),
date: p.completedAt || p.initiatedAt || p.createdAt,
reference: p.reference,
reason: p.reason,
bankName: p.bankName,
accountEnding: p.accountNumber
? '****' + p.accountNumber.slice(-4)
: null,
};
});
}
function mapStatusForDisplay(status) {
var map = {
pending_approval: 'Pending',
approved: 'Scheduled',
processing: 'Processing',
completed: 'Paid',
failed: 'Failed',
reversed: 'Reversed',
retry_scheduled: 'Retrying',
};
return map[status] || status;
}
function getStatusColor(status) {
if (status === 'completed') return 'green';
if (status === 'failed' || status === 'reversed') return 'red';
if (status === 'processing') return 'yellow';
return 'gray';
}
Show enough detail for the vendor to identify each payout without exposing internal system information. The reference, amount, date, and status are sufficient. Mask the bank account number (show only the last 4 digits) for security. If a payout failed, show a generic message like "Payment failed, will be retried" rather than the raw Paystack error message.
async function updateVendorBankDetails(vendorId, newBankCode, newAccountNumber) {
// Step 1: Resolve the new account
var resolved = await resolveAccount(newAccountNumber, newBankCode);
if (!resolved) {
throw new Error('Could not verify the bank account. Check the number and bank.');
}
// Step 2: Show the resolved name for confirmation
// (Return this to the frontend, wait for user confirmation)
return {
needsConfirmation: true,
resolvedAccountName: resolved.account_name,
bankCode: newBankCode,
accountNumber: newAccountNumber,
};
}
async function confirmBankUpdate(vendorId, newBankCode, newAccountNumber, resolvedName) {
// Step 3: Create new recipient on Paystack
var newRecipient = await createRecipient(
resolvedName, newAccountNumber, newBankCode
);
// Step 4: Update vendor record
var oldRecipientCode = (await db.vendors.findById(vendorId)).paystackRecipientCode;
await db.vendors.update(vendorId, {
bankCode: newBankCode,
accountNumber: newAccountNumber,
resolvedAccountName: resolvedName,
paystackRecipientCode: newRecipient.recipientCode,
bankUpdatedAt: new Date(),
});
// Step 5: Log the change
await db.auditLog.create({
action: 'bank_details_updated',
vendorId: vendorId,
details: 'Old recipient: ' + oldRecipientCode
+ ', New recipient: ' + newRecipient.recipientCode,
});
}
The two-step process (resolve, then confirm) is critical. It prevents vendors from accidentally entering wrong details. They see the resolved account name and confirm it is correct before the update takes effect. Any pending payouts continue using the old recipient code; only new payouts use the updated details.
Some platforms let vendors request payouts on demand instead of waiting for the scheduled cycle. This is a premium feature that requires careful controls.
async function requestWithdrawal(vendorId, amount) {
// Check available balance
var earnings = await getVendorEarnings(vendorId);
if (amount > earnings.availableForWithdrawal) {
throw new Error(
'Requested amount exceeds available balance. Available: '
+ (earnings.availableForWithdrawal / 100)
);
}
// Check daily withdrawal limit
var todayWithdrawals = await db.payouts.sumTodayByVendor(vendorId);
var dailyLimit = 10000000; // 100,000 NGN
if (todayWithdrawals + amount > dailyLimit) {
throw new Error('Daily withdrawal limit reached');
}
// Check minimum withdrawal amount
var minWithdrawal = 100000; // 1,000 NGN
if (amount < minWithdrawal) {
throw new Error('Minimum withdrawal is ' + (minWithdrawal / 100));
}
// Create the payout request
var payout = await db.payouts.create({
vendorId: vendorId,
recipientCode: (await db.vendors.findById(vendorId)).paystackRecipientCode,
amountMinorUnit: amount,
reason: 'Vendor withdrawal request',
reference: 'withdrawal_' + vendorId + '_' + Date.now(),
status: 'approved', // Auto-approved for on-demand
makerId: 'vendor_self',
createdAt: new Date(),
});
// Execute immediately
await processPayout(payout.id);
return payout;
}
On-demand withdrawals need three safeguards: a maximum daily limit (prevents a compromised vendor account from draining their balance), a minimum amount (prevents micro-withdrawals that incur fees), and a verified available balance (ensures you do not pay out more than the vendor has earned).
Vendors want to know when they will next be paid and how much. This reduces support inquiries and builds trust.
async function getNextPayoutInfo(vendorId) {
var vendor = await db.vendors.findById(vendorId);
var nextPayoutDate = getNextScheduledPayoutDate(vendor.payoutFrequency);
var earnings = await getVendorEarnings(
vendorId,
getPayoutPeriodStart(nextPayoutDate),
new Date()
);
return {
nextPayoutDate: nextPayoutDate,
estimatedAmount: earnings.availableForWithdrawal / 100,
currency: vendor.currency,
payoutMethod: vendor.paystackRecipientCode
? (vendor.recipientType === 'mpesa' ? 'M-Pesa' : 'Bank Transfer')
: 'Not set up',
bankName: vendor.bankName || null,
accountEnding: vendor.accountNumber
? '****' + vendor.accountNumber.slice(-4)
: null,
};
}
Show this prominently on the vendor dashboard. "Your next payout of approximately 45,000 KES is scheduled for Wednesday, July 23." That one sentence answers the question the vendor would otherwise email your support team about.
For the full automated payout system that powers this portal, see building an automated payout system on Paystack.
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