Building a Vendor Payout Portal on Paystack
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.
What Vendors Need to See
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:
- Earnings summary: Total earned this period, total earned all time, amount pending (not yet paid out), amount available for withdrawal
- Payout history: A table of past payouts with status, amount, date, and reference. Filterable by date range and status.
- Bank details: Their current bank account on file, with an option to update it
- Next payout: When the next scheduled payout will run and the estimated amount
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.
Building the Earnings Dashboard
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.
Payout History Table
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.
Self-Service Bank Detail Management
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.
On-Demand Withdrawal Requests
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).
Showing Next Payout Information
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.
Key Takeaways
- ✓Vendors need four things: how much they have earned, how much has been paid out, when the next payout is coming, and the ability to update their bank details.
- ✓Build an earnings dashboard that shows total earnings, pending settlement, and paid out amounts per period.
- ✓Payout history should show each transfer with its status (processing, completed, failed), amount, date, and reference.
- ✓Self-service bank detail updates reduce support tickets. Let vendors change their bank through the portal with proper validation.
- ✓On-demand withdrawals are a premium feature. Implement daily limits and balance checks before allowing instant payouts.
- ✓The portal reads from your database, not from Paystack directly. Keep your payout records synchronized via webhooks.
Frequently Asked Questions
- Should the portal show real-time data from Paystack?
- No. The portal should read from your database, which is kept in sync with Paystack through webhooks. Calling Paystack APIs on every vendor page load would be slow, unreliable (if Paystack is slow, your portal is slow), and unnecessary. Keep your local data accurate through webhooks and reconciliation.
- How should I handle showing failed payouts to vendors?
- Keep the message simple and actionable. "Your payout could not be processed. Our team is looking into it." or "Your bank details may need updating. Please verify your account information." Do not show raw Paystack error messages, which can be confusing or alarming.
- Should I let vendors see the platform commission on each order?
- Yes. Transparency builds trust. Show the gross order amount, the platform commission, and the net amount the vendor receives. Vendors who understand the commission structure are less likely to dispute payouts.
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