To build a rider payout system with Paystack, collect customer payments into your Paystack balance, calculate each rider's earnings minus your commission, create transfer recipients for each rider's M-Pesa number, and use Paystack Transfers to disburse funds. For batch payouts, use the Bulk Transfer API. Always check your Paystack balance before initiating transfers, handle transfer.failed webhooks with a retry queue, and reconcile daily.
A boda boda payout system has two sides: collecting money from customers and disbursing it to riders. The money flows in, you take your commission, and the rest goes out.
Here is how the pieces fit together:
The critical constraint: you can only transfer money that is already in your Paystack balance. If a customer paid today but Paystack has not settled those funds yet, you cannot disburse them through Transfers. Your payout schedule must account for Paystack's settlement cycle. Check your settlement timeline with Paystack, as it varies by account type.
Some platforms pre-fund their Paystack balance to avoid this delay. You transfer money from your bank account into Paystack, then disburse immediately after rides complete. This requires working capital but lets you offer same-day payouts, which is a competitive advantage for rider retention.
The data model tracks riders, trips, earnings, and payouts. Every shilling must be accounted for.
CREATE TABLE riders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
phone VARCHAR(20) NOT NULL UNIQUE,
national_id VARCHAR(20),
bike_registration VARCHAR(20),
paystack_recipient_code VARCHAR(100), -- stored after first recipient creation
commission_rate DECIMAL(5,4) DEFAULT 0.15, -- 15% default, adjustable per rider
is_active BOOLEAN DEFAULT TRUE,
total_earned_cents BIGINT DEFAULT 0,
total_paid_cents BIGINT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE trips (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rider_id UUID REFERENCES riders(id) NOT NULL,
customer_phone VARCHAR(20),
fare_cents INTEGER NOT NULL,
commission_cents INTEGER NOT NULL,
rider_earning_cents INTEGER NOT NULL,
distance_km DECIMAL(6,2),
pickup_location VARCHAR(255),
dropoff_location VARCHAR(255),
payment_method VARCHAR(20), -- 'mpesa', 'card', 'cash'
paystack_reference VARCHAR(255),
payment_status VARCHAR(20) DEFAULT 'pending', -- pending, paid, failed
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE payout_batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
period_start TIMESTAMPTZ NOT NULL,
period_end TIMESTAMPTZ NOT NULL,
total_riders INTEGER DEFAULT 0,
total_amount_cents BIGINT DEFAULT 0,
status VARCHAR(20) DEFAULT 'pending', -- pending, processing, completed, partial
paystack_batch_reference VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE payouts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
batch_id UUID REFERENCES payout_batches(id),
rider_id UUID REFERENCES riders(id) NOT NULL,
amount_cents INTEGER NOT NULL,
trip_count INTEGER NOT NULL,
paystack_transfer_code VARCHAR(100),
paystack_reference VARCHAR(255),
status VARCHAR(20) DEFAULT 'pending', -- pending, processing, success, failed, reversed
failure_reason VARCHAR(500),
retry_count INTEGER DEFAULT 0,
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE payout_line_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
payout_id UUID REFERENCES payouts(id),
trip_id UUID REFERENCES trips(id),
earning_cents INTEGER NOT NULL
);
Key design choices:
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Before you can send money to a rider, you need to register them as a Paystack transfer recipient. You do this once per rider and store the recipient_code for future payouts.
async function createRiderRecipient(rider) {
// Check if already registered
if (rider.paystack_recipient_code) {
return rider.paystack_recipient_code;
}
const res = await fetch('https://api.paystack.co/transferrecipient', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: 'mobile_money',
name: rider.name,
account_number: rider.phone, // M-Pesa phone number
bank_code: 'MPESA',
currency: 'KES',
metadata: {
rider_id: rider.id,
national_id: rider.national_id,
},
}),
});
const data = await res.json();
if (!data.status) {
throw new Error(`Failed to create recipient: ${data.message}`);
}
// Store the recipient code
await db.query(
'UPDATE riders SET paystack_recipient_code = $1 WHERE id = $2',
[data.data.recipient_code, rider.id]
);
return data.data.recipient_code;
}
// Register recipient when rider onboards
app.post('/api/riders/register', async (req, res) => {
const { name, phone, nationalId, bikeRegistration } = req.body;
const rider = await db.query(
`INSERT INTO riders (name, phone, national_id, bike_registration)
VALUES ($1, $2, $3, $4) RETURNING *`,
[name, phone, nationalId, bikeRegistration]
);
// Create the Paystack recipient immediately
try {
await createRiderRecipient(rider.rows[0]);
} catch (err) {
console.error('Recipient creation failed, will retry later:', err.message);
// Do not block rider registration. Retry before first payout.
}
res.json({ rider: rider.rows[0] });
});
A few practical notes:
Commission calculation must be dead simple and fully transparent. Riders will question every shilling. If your math is wrong or hard to explain, you will lose riders.
function calculateTripEarnings(fareCents, commissionRate) {
const commissionCents = Math.floor(fareCents * commissionRate);
const riderEarningCents = fareCents - commissionCents;
return {
fareCents,
commissionCents,
riderEarningCents,
commissionRate,
};
}
// When recording a completed trip
app.post('/api/trips/complete', async (req, res) => {
const { riderId, fareCents, customerPhone, distance, pickup, dropoff, paymentMethod, paystackRef } = req.body;
const rider = await db.query('SELECT * FROM riders WHERE id = $1', [riderId]);
if (!rider.rows[0]) return res.status(404).json({ error: 'Rider not found' });
const earnings = calculateTripEarnings(fareCents, parseFloat(rider.rows[0].commission_rate));
const trip = await db.query(
`INSERT INTO trips
(rider_id, fare_cents, commission_cents, rider_earning_cents,
customer_phone, distance_km, pickup_location, dropoff_location,
payment_method, paystack_reference, payment_status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *`,
[
riderId, earnings.fareCents, earnings.commissionCents, earnings.riderEarningCents,
customerPhone, distance, pickup, dropoff,
paymentMethod, paystackRef, paymentMethod === 'cash' ? 'paid' : 'pending',
]
);
// Update rider's running total
await db.query(
'UPDATE riders SET total_earned_cents = total_earned_cents + $1 WHERE id = $2',
[earnings.riderEarningCents, riderId]
);
res.json({ trip: trip.rows[0] });
});
Use Math.floor for the commission, not Math.round. When there is a fractional cent, give the fraction to the rider, not the platform. This is a small thing, but it matters for trust. At scale, the rounding difference is negligible for your revenue but visible on individual trip receipts.
Store the commission rate at the time of the trip, not just on the rider record. If you change a rider's commission rate, historical trips should still show the rate that was in effect when the trip happened. Add a commission_rate_at_trip column if needed, or rely on the stored commission_cents and fare_cents to back-calculate it.
Cash trips. Not every customer pays digitally. When the customer pays cash, the rider keeps the cash. At payout time, deduct the rider's share of cash trips from the digital payout. If a rider earned KES 5,000 digitally and KES 2,000 in cash, and cash trips had KES 300 in commission, the rider already has KES 2,000 in hand but owes you KES 300 in commission. The digital payout is then the digital earnings minus any commission owed from cash trips.
The payout scheduler runs at the end of each payout period (daily or weekly), calculates what each rider is owed, and initiates the transfers.
async function runPayoutCycle() {
const periodEnd = new Date();
const periodStart = new Date(periodEnd.getTime() - 24 * 60 * 60 * 1000); // Last 24 hours
// 1. Check Paystack balance first
const balanceRes = await fetch('https://api.paystack.co/balance', {
headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` },
});
const balanceData = await balanceRes.json();
const availableBalance = balanceData.data.find(b => b.currency === 'KES')?.balance || 0;
// 2. Calculate earnings per rider for the period
const riderEarnings = await db.query(
`SELECT
r.id as rider_id,
r.name,
r.phone,
r.paystack_recipient_code,
COUNT(t.id) as trip_count,
SUM(t.rider_earning_cents) as total_earning_cents,
SUM(CASE WHEN t.payment_method = 'cash' THEN t.commission_cents ELSE 0 END) as cash_commission_owed
FROM riders r
JOIN trips t ON t.rider_id = r.id
WHERE t.created_at >= $1 AND t.created_at < $2
AND t.payment_status = 'paid'
GROUP BY r.id, r.name, r.phone, r.paystack_recipient_code
HAVING SUM(t.rider_earning_cents) > 0`,
[periodStart, periodEnd]
);
// 3. Calculate net payouts (digital earnings minus cash commission owed)
const payouts = riderEarnings.rows
.map(rider => {
// Digital earnings are what we owe them
// Cash commission is what they owe us (deducted from payout)
const digitalEarnings = rider.total_earning_cents - (rider.cash_commission_owed || 0);
return {
...rider,
payout_amount_cents: Math.max(0, digitalEarnings),
};
})
.filter(r => r.payout_amount_cents > 0 && r.paystack_recipient_code);
const totalPayoutAmount = payouts.reduce((sum, p) => sum + p.payout_amount_cents, 0);
// 4. Verify balance is sufficient
if (availableBalance < totalPayoutAmount) {
console.error('Insufficient Paystack balance for payouts', {
available: availableBalance,
needed: totalPayoutAmount,
});
// Alert the operations team
await sendAlert('Insufficient balance for rider payouts');
return;
}
// 5. Create the payout batch
const batch = await db.query(
`INSERT INTO payout_batches (period_start, period_end, total_riders, total_amount_cents, status)
VALUES ($1, $2, $3, $4, 'processing') RETURNING id`,
[periodStart, periodEnd, payouts.length, totalPayoutAmount]
);
// 6. Initiate transfers
for (const payout of payouts) {
await initiateSinglePayout(batch.rows[0].id, payout);
}
}
async function initiateSinglePayout(batchId, riderPayout) {
const payout = await db.query(
`INSERT INTO payouts (batch_id, rider_id, amount_cents, trip_count, status)
VALUES ($1, $2, $3, $4, 'processing') RETURNING id`,
[batchId, riderPayout.rider_id, riderPayout.payout_amount_cents, parseInt(riderPayout.trip_count)]
);
try {
const transferRes = await fetch('https://api.paystack.co/transfer', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
source: 'balance',
amount: riderPayout.payout_amount_cents,
recipient: riderPayout.paystack_recipient_code,
reason: `Rider payout: ${riderPayout.trip_count} trips`,
reference: `payout-${payout.rows[0].id}`,
metadata: {
payout_id: payout.rows[0].id,
batch_id: batchId,
rider_id: riderPayout.rider_id,
},
}),
});
const transferData = await transferRes.json();
await db.query(
'UPDATE payouts SET paystack_transfer_code = $1, paystack_reference = $2 WHERE id = $3',
[transferData.data.transfer_code, transferData.data.reference, payout.rows[0].id]
);
} catch (err) {
await db.query(
"UPDATE payouts SET status = 'failed', failure_reason = $1 WHERE id = $2",
[err.message, payout.rows[0].id]
);
}
}
Run this job during business hours (6 PM to 8 PM EAT is a good window for daily payouts). Riders want their money before the end of the day. Running it at 3 AM means the money arrives while they are asleep, which is fine for weekly payouts but suboptimal for daily ones.
When you have dozens or hundreds of riders to pay at once, use Paystack's Bulk Transfer API instead of individual transfer calls. It is more efficient and gives you a single batch reference to track.
async function runBulkPayout(batchId, payouts) {
const transfers = payouts.map(p => ({
amount: p.payout_amount_cents,
recipient: p.paystack_recipient_code,
reason: `Rider payout: ${p.trip_count} trips`,
reference: `payout-${p.payout_id}`,
}));
const res = await fetch('https://api.paystack.co/transfer/bulk', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
source: 'balance',
currency: 'KES',
transfers: transfers,
}),
});
const data = await res.json();
if (!data.status) {
throw new Error(`Bulk transfer failed: ${data.message}`);
}
// Update batch with Paystack reference
await db.query(
'UPDATE payout_batches SET paystack_batch_reference = $1 WHERE id = $2',
[data.data?.batch_reference || 'submitted', batchId]
);
return data;
}
Important limits and considerations:
transfer.success or transfer.failed for each rider separately, not one event for the whole batch.M-Pesa transfers fail. The rider's phone might be off. The wallet might be full (M-Pesa has a wallet balance limit). The number might have been deregistered. Safaricom might be having issues. Your system must handle all of these.
// Webhook handler for transfer events
async function handleTransferWebhook(event) {
const { reference, status, reason } = event.data;
// Extract payout ID from reference
const payoutId = reference.replace('payout-', '');
if (event.event === 'transfer.success') {
await db.query(
"UPDATE payouts SET status = 'success', paid_at = NOW() WHERE id = $1",
[payoutId]
);
// Update rider's total paid
const payout = await db.query('SELECT rider_id, amount_cents FROM payouts WHERE id = $1', [payoutId]);
if (payout.rows[0]) {
await db.query(
'UPDATE riders SET total_paid_cents = total_paid_cents + $1 WHERE id = $2',
[payout.rows[0].amount_cents, payout.rows[0].rider_id]
);
}
}
if (event.event === 'transfer.failed') {
const payout = await db.query('SELECT * FROM payouts WHERE id = $1', [payoutId]);
await db.query(
"UPDATE payouts SET status = 'failed', failure_reason = $1 WHERE id = $2",
[reason || 'Transfer failed', payoutId]
);
// Retry if under the retry limit
if (payout.rows[0] && payout.rows[0].retry_count < 3) {
await schedulePayoutRetry(payoutId);
} else {
// Alert operations team for manual intervention
await sendAlert(`Payout ${payoutId} failed after 3 retries: ${reason}`);
}
}
if (event.event === 'transfer.reversed') {
// The transfer was reversed by Paystack or the bank
await db.query(
"UPDATE payouts SET status = 'reversed', failure_reason = 'Transfer reversed' WHERE id = $1",
[payoutId]
);
// Reverse the rider's paid total
const payout = await db.query('SELECT rider_id, amount_cents FROM payouts WHERE id = $1', [payoutId]);
if (payout.rows[0]) {
await db.query(
'UPDATE riders SET total_paid_cents = total_paid_cents - $1 WHERE id = $2',
[payout.rows[0].amount_cents, payout.rows[0].rider_id]
);
}
}
}
async function schedulePayoutRetry(payoutId) {
// Wait 2 hours before retrying
const payout = await db.query('SELECT * FROM payouts WHERE id = $1', [payoutId]);
const rider = await db.query('SELECT * FROM riders WHERE id = $1', [payout.rows[0].rider_id]);
await db.query(
'UPDATE payouts SET retry_count = retry_count + 1, status = $1 WHERE id = $2',
['pending', payoutId]
);
// Retry the transfer
const transferRes = await fetch('https://api.paystack.co/transfer', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
source: 'balance',
amount: payout.rows[0].amount_cents,
recipient: rider.rows[0].paystack_recipient_code,
reason: `Retry payout: attempt ${payout.rows[0].retry_count + 1}`,
reference: `payout-${payoutId}-retry-${payout.rows[0].retry_count + 1}`,
}),
});
const data = await transferRes.json();
await db.query(
"UPDATE payouts SET paystack_transfer_code = $1, status = 'processing' WHERE id = $2",
[data.data?.transfer_code, payoutId]
);
}
Common failure reasons and what to do about them:
At the end of each payout cycle, reconcile what you intended to pay against what actually landed in riders' M-Pesa wallets. This protects both you and the riders.
async function reconcilePayoutBatch(batchId) {
const batch = await db.query('SELECT * FROM payout_batches WHERE id = $1', [batchId]);
const payouts = await db.query('SELECT * FROM payouts WHERE batch_id = $1', [batchId]);
const summary = {
total_payouts: payouts.rows.length,
successful: payouts.rows.filter(p => p.status === 'success').length,
failed: payouts.rows.filter(p => p.status === 'failed').length,
pending: payouts.rows.filter(p => p.status === 'processing' || p.status === 'pending').length,
reversed: payouts.rows.filter(p => p.status === 'reversed').length,
total_intended_cents: payouts.rows.reduce((sum, p) => sum + p.amount_cents, 0),
total_paid_cents: payouts.rows
.filter(p => p.status === 'success')
.reduce((sum, p) => sum + p.amount_cents, 0),
};
summary.discrepancy_cents = summary.total_intended_cents - summary.total_paid_cents;
// Update batch status
const batchStatus = summary.failed > 0 ? 'partial' : 'completed';
await db.query(
'UPDATE payout_batches SET status = $1 WHERE id = $2',
[batchStatus, batchId]
);
return summary;
}
Generate a daily reconciliation report that your operations team reviews. The report should answer these questions:
If the numbers do not balance, you have a problem. Either a payment was missed, a payout was duplicated, or a commission was calculated wrong. The reconciliation process catches these issues before they compound.
Enable transfers on your Paystack account. Transfers are not enabled by default. You need to request access through your Paystack dashboard or by contacting their support. This can take a few days for verification, so do it early in your development process.
OTP for transfers. Paystack may require OTP authorization for transfers, especially large ones. For automated payouts, you can disable OTP on your account (Paystack calls this "auto-finalize"). Contact Paystack to set this up. Without it, every transfer requires a manual approval step that breaks automation.
Transfer limits. Paystack has per-transaction and daily transfer limits that depend on your account tier. For a payout system that serves hundreds of riders, you may hit these limits. Request limit increases from Paystack before launching.
Pre-fund your balance. If you want same-day payouts, you need money in your Paystack balance before the payout cycle runs. Set up a process to transfer working capital from your bank account to Paystack regularly.
Rider communication. Send an SMS when a payout is initiated and another when it succeeds. Riders live on their phones. An SMS that says "KES 3,450 sent to your M-Pesa for 23 trips" builds trust. When a payout fails, tell the rider immediately and give a timeline for the retry.
Tax considerations. Depending on how you structure the rider relationship (contractor vs. employee), you may have withholding tax obligations. Consult with a Kenyan tax advisor. Your payout system should be able to generate per-rider earning summaries for tax reporting.
For the full Paystack Transfers guide, see Paystack Transfers to M-Pesa Wallets Explained. For the Kenya payment context, see Paystack in Kenya: M-Pesa, Pesalink, and the Daraja Question.
The McTaba 26-week bootcamp (KES 120,000) covers payment integration including transfers and payouts. You build a real product that collects and disburses real money.
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