Build a Kenyan delivery app with rider payouts by accepting customer orders and payments through Paystack (M-Pesa and card), calculating the platform commission per delivery, accumulating rider earnings in your database, creating Paystack Transfer Recipients for each rider M-Pesa wallet, and disbursing earnings through the Transfers API on a daily or weekly schedule. Use webhooks to track transfer success and failure, and build an earnings dashboard so riders see their balance in real time.
A delivery app handles three-way money flow. The customer pays for the delivery. The platform takes a commission. The rider gets the rest. Understanding this flow is the foundation of everything you build.
Example. A customer orders food delivery for KES 300 (delivery fee). The platform takes 20% commission (KES 60). The rider earns KES 240. The customer pays KES 300 through Paystack. After Paystack fees, the net amount lands in your Paystack balance. You then transfer the rider's share to their M-Pesa wallet through Paystack Transfers.
This means your Paystack balance serves as a holding account. Money comes in from customers and goes out to riders. The difference (your commission minus Paystack fees on both sides) is your revenue.
Why not send money directly to riders? Because you need to deduct your commission, handle disputes, manage refunds, and maintain a financial record. If a customer complains about a delivery and you need to issue a refund, you cannot claw back money already sent to a rider's M-Pesa. By holding funds and paying out on a schedule, you maintain control over the financial flow.
Paystack provides both sides of this equation: the Initialize Transaction API for collecting payments from customers, and the Transfers API for sending payouts to riders. You build the commission logic and payout scheduling in between.
Your database needs to track three things precisely: what the customer paid, what the platform earned, and what the rider is owed.
const schema = `
CREATE TABLE riders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
phone TEXT UNIQUE NOT NULL,
mpesa_number TEXT NOT NULL,
paystack_recipient_code TEXT,
status TEXT DEFAULT 'active',
pending_balance INT DEFAULT 0, -- amount owed, in cents
total_earned INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE orders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_phone TEXT NOT NULL,
pickup_location TEXT NOT NULL,
dropoff_location TEXT NOT NULL,
delivery_fee INT NOT NULL, -- what customer pays
platform_commission INT NOT NULL, -- platform cut
rider_earnings INT NOT NULL, -- rider share
paystack_reference TEXT UNIQUE,
payment_status TEXT DEFAULT 'pending',
rider_id UUID REFERENCES riders(id),
delivery_status TEXT DEFAULT 'pending',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE rider_ledger (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rider_id UUID REFERENCES riders(id),
order_id UUID REFERENCES orders(id),
type TEXT NOT NULL, -- 'earning', 'payout', 'adjustment', 'penalty'
amount INT NOT NULL, -- positive for earnings, negative for payouts
balance_after INT NOT NULL, -- running balance
reference TEXT, -- Paystack reference for payouts
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE payouts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rider_id UUID REFERENCES riders(id),
amount INT NOT NULL,
paystack_transfer_code TEXT,
paystack_reference TEXT UNIQUE,
status TEXT DEFAULT 'pending', -- pending, success, failed, reversed
created_at TIMESTAMPTZ DEFAULT NOW()
);
`;
The rider_ledger table is critical. It records every financial event for a rider: earnings from completed deliveries, payouts sent, adjustments (bonuses or penalties), and corrections. The balance_after column gives you a running balance at any point in time. This is your audit trail and the basis for the rider's earnings dashboard.
The riders table has a pending_balance column that you update with each ledger entry. This is a denormalized convenience field for fast reads (the dashboard queries it directly). The ledger is the source of truth; pending_balance is a cache.
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 customer places an order, initialize a Paystack transaction for the delivery fee. M-Pesa first, as always in Kenya.
async function createDeliveryOrder(orderData) {
const deliveryFee = calculateDeliveryFee(orderData.distance, orderData.urgency);
const commissionRate = 0.20; // 20% platform commission
const platformCommission = Math.floor(deliveryFee * commissionRate);
const riderEarnings = deliveryFee - platformCommission;
const reference = `delivery_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
// Create order record
const order = await db.query(
`INSERT INTO orders
(customer_phone, pickup_location, dropoff_location, delivery_fee,
platform_commission, rider_earnings, paystack_reference, payment_status)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending')
RETURNING *`,
[
orderData.phone, orderData.pickup, orderData.dropoff,
deliveryFee, platformCommission, riderEarnings, reference,
]
);
// Initialize Paystack transaction
const response = await axios.post(
'https://api.paystack.co/transaction/initialize',
{
email: `${orderData.phone}@delivery.yourapp.com`,
amount: deliveryFee,
reference: reference,
currency: 'KES',
channels: ['mobile_money', 'card'],
callback_url: `https://yourapp.com/orders/${order.rows[0].id}`,
metadata: {
order_id: order.rows[0].id,
customer_phone: orderData.phone,
},
},
{ headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` } }
);
return {
order: order.rows[0],
paymentUrl: response.data.data.authorization_url,
};
}
Once the charge.success webhook fires and you verify the transaction, update the order to payment_status = 'paid' and begin rider assignment. Do not assign a rider before payment is confirmed. Riders waiting for unpaid orders is a waste of their time and your credibility.
async function handleDeliveryPaymentSuccess(reference) {
const verification = await verifyTransaction(reference);
if (verification.status !== 'success') return;
const order = await db.query(
`UPDATE orders SET payment_status = 'paid'
WHERE paystack_reference = $1 AND payment_status = 'pending'
RETURNING *`,
[reference]
);
if (order.rowCount === 0) return;
// Trigger rider assignment (your matching algorithm)
await assignRiderToOrder(order.rows[0]);
}
When a delivery is completed, record the rider's earnings in the ledger and update their pending balance.
async function recordDeliveryEarnings(orderId, riderId) {
const order = await db.query('SELECT * FROM orders WHERE id = $1', [orderId]);
if (!order.rows[0]) throw new Error('Order not found');
const earnings = order.rows[0].rider_earnings;
await db.query('BEGIN');
try {
// Get current balance
const rider = await db.query(
'SELECT pending_balance FROM riders WHERE id = $1 FOR UPDATE',
[riderId]
);
const newBalance = rider.rows[0].pending_balance + earnings;
// Record in ledger
await db.query(
`INSERT INTO rider_ledger (rider_id, order_id, type, amount, balance_after, notes)
VALUES ($1, $2, 'earning', $3, $4, $5)`,
[riderId, orderId, earnings, newBalance, `Delivery earnings for order ${orderId}`]
);
// Update rider balance
await db.query(
'UPDATE riders SET pending_balance = $1, total_earned = total_earned + $2 WHERE id = $3',
[newBalance, earnings, riderId]
);
// Mark order as completed
await db.query(
'UPDATE orders SET delivery_status = $1, rider_id = $2 WHERE id = $3',
['completed', riderId, orderId]
);
await db.query('COMMIT');
} catch (err) {
await db.query('ROLLBACK');
throw err;
}
}
Variable commissions. Not every delivery needs the same commission rate. You might charge lower commissions during off-peak hours to incentivize riders, or higher commissions for premium delivery (express, fragile items). Store the commission rate per order, not as a global config.
Bonuses and penalties. Riders earn bonuses for completing a certain number of trips per day, working during rain, or maintaining high ratings. Penalties apply for cancelled deliveries or customer complaints. Both are recorded in the ledger as separate entries with type 'adjustment' or 'penalty'. This keeps earnings transparent and auditable.
async function applyRiderBonus(riderId, amount, reason) {
const rider = await db.query(
'SELECT pending_balance FROM riders WHERE id = $1 FOR UPDATE',
[riderId]
);
const newBalance = rider.rows[0].pending_balance + amount;
await db.query(
`INSERT INTO rider_ledger (rider_id, type, amount, balance_after, notes)
VALUES ($1, 'adjustment', $2, $3, $4)`,
[riderId, amount, newBalance, reason]
);
await db.query('UPDATE riders SET pending_balance = $1 WHERE id = $2', [newBalance, riderId]);
}
Before you can pay a rider, register their M-Pesa number as a Paystack Transfer Recipient. Do this once during rider onboarding and store the recipient_code.
async function registerRiderForPayouts(riderId) {
const rider = await db.query('SELECT * FROM riders WHERE id = $1', [riderId]);
if (!rider.rows[0]) throw new Error('Rider not found');
const { name, mpesa_number } = rider.rows[0];
const response = await axios.post(
'https://api.paystack.co/transferrecipient',
{
type: 'mobile_money',
name: name,
account_number: mpesa_number,
bank_code: 'MPESA',
currency: 'KES',
},
{ headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` } }
);
const recipientCode = response.data.data.recipient_code;
await db.query(
'UPDATE riders SET paystack_recipient_code = $1 WHERE id = $2',
[recipientCode, riderId]
);
return recipientCode;
}
Phone number changes. When a rider changes their M-Pesa number, create a new Transfer Recipient with the updated number. Do not reuse the old recipient_code. Update the rider record with the new code. Old payouts already sent to the previous number are not affected.
Validation. Verify the phone number format before creating the recipient. Kenyan M-Pesa numbers start with 07xx or 01xx. Paystack may also accept the 254 prefix. Standardize the format in your app (e.g., always store as 254712345678) and convert when needed for the API call or display.
Pay riders on a schedule rather than per delivery. Per-delivery payouts are operationally complex, generate high transfer volumes, and each transfer may carry its own fee. Batch payouts simplify everything.
Daily payouts. Most Kenyan delivery riders prefer daily payouts. Run a payout job every evening (or early morning for the previous day). The job collects all riders with a pending_balance above a minimum threshold and sends transfers in bulk.
async function runDailyPayouts() {
const minimumPayout = 10000; // KES 100 in cents
// Get all riders with sufficient balance
const riders = await db.query(
`SELECT id, name, mpesa_number, paystack_recipient_code, pending_balance
FROM riders
WHERE pending_balance >= $1
AND paystack_recipient_code IS NOT NULL
AND status = 'active'`,
[minimumPayout]
);
if (riders.rows.length === 0) return;
const transfers = [];
for (const rider of riders.rows) {
const reference = `payout_${rider.id}_${Date.now()}`;
// Create payout record
await db.query(
`INSERT INTO payouts (rider_id, amount, paystack_reference, status)
VALUES ($1, $2, $3, 'pending')`,
[rider.id, rider.pending_balance, reference]
);
transfers.push({
amount: rider.pending_balance,
recipient: rider.paystack_recipient_code,
reason: `Delivery earnings payout - ${new Date().toISOString().split('T')[0]}`,
reference: reference,
});
}
// Send bulk transfer
const response = await axios.post(
'https://api.paystack.co/transfer/bulk',
{
currency: 'KES',
source: 'balance',
transfers: transfers,
},
{ headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` } }
);
return response.data;
}
Important: do not deduct from pending_balance when you initiate the transfer. Wait until you receive the transfer.success webhook. If the transfer fails (wrong number, insufficient balance on your Paystack account, network error), you do not want the rider's balance to show zero when they have not actually been paid.
async function handleTransferSuccess(data) {
const reference = data.reference;
const payout = await db.query(
`UPDATE payouts SET status = 'success', paystack_transfer_code = $1
WHERE paystack_reference = $2 AND status = 'pending'
RETURNING *`,
[data.transfer_code, reference]
);
if (payout.rowCount === 0) return;
const { rider_id, amount } = payout.rows[0];
await db.query('BEGIN');
try {
const rider = await db.query(
'SELECT pending_balance FROM riders WHERE id = $1 FOR UPDATE',
[rider_id]
);
const newBalance = rider.rows[0].pending_balance - amount;
// Record payout in ledger
await db.query(
`INSERT INTO rider_ledger (rider_id, type, amount, balance_after, reference, notes)
VALUES ($1, 'payout', $2, $3, $4, 'Daily payout')`,
[rider_id, -amount, newBalance, reference]
);
// Deduct from pending balance
await db.query('UPDATE riders SET pending_balance = $1 WHERE id = $2', [newBalance, rider_id]);
await db.query('COMMIT');
} catch (err) {
await db.query('ROLLBACK');
throw err;
}
// Notify rider via SMS
await sendSMS(
payout.rows[0].mpesa_number,
`Payout of KES ${amount / 100} sent to your M-Pesa. New balance: KES ${newBalance / 100}.`
);
}
async function handleTransferFailed(data) {
const reference = data.reference;
await db.query(
'UPDATE payouts SET status = $1 WHERE paystack_reference = $2',
['failed', reference]
);
// Alert operations team for manual review
await notifyOpsTeam(`Payout failed: ${reference}. Reason: ${data.reason}`);
}
Balance checks. Before initiating bulk transfers, verify that your Paystack balance can cover the total payout amount. Use the Balance API endpoint. If the balance is insufficient, prioritize payouts by rider seniority or earning amount, and process the rest in the next batch.
Riders check their earnings constantly. If they cannot see what they have earned, what is pending, and when the next payout is coming, they will flood your support channels with questions. A good earnings dashboard eliminates 80% of rider payment queries.
Dashboard data points. Today's earnings (trips completed today), pending balance (what they are owed), last payout (date and amount), week's total, and a trip-by-trip breakdown for the current day.
async function getRiderDashboard(riderId) {
const today = new Date().toISOString().split('T')[0];
const [rider, todayEarnings, recentTrips, lastPayout, weekTotal] = await Promise.all([
db.query('SELECT pending_balance, total_earned FROM riders WHERE id = $1', [riderId]),
db.query(
`SELECT COALESCE(SUM(amount), 0) as total
FROM rider_ledger
WHERE rider_id = $1 AND type = 'earning' AND DATE(created_at) = $2`,
[riderId, today]
),
db.query(
`SELECT rl.amount, rl.created_at, o.pickup_location, o.dropoff_location
FROM rider_ledger rl
LEFT JOIN orders o ON rl.order_id = o.id
WHERE rl.rider_id = $1 AND rl.type = 'earning'
ORDER BY rl.created_at DESC LIMIT 20`,
[riderId]
),
db.query(
`SELECT amount, created_at FROM payouts
WHERE rider_id = $1 AND status = 'success'
ORDER BY created_at DESC LIMIT 1`,
[riderId]
),
db.query(
`SELECT COALESCE(SUM(amount), 0) as total
FROM rider_ledger
WHERE rider_id = $1 AND type = 'earning'
AND created_at >= date_trunc('week', NOW())`,
[riderId]
),
]);
return {
pendingBalance: rider.rows[0].pending_balance,
totalEarned: rider.rows[0].total_earned,
todayEarnings: todayEarnings.rows[0].total,
weekTotal: weekTotal.rows[0].total,
recentTrips: recentTrips.rows,
lastPayout: lastPayout.rows[0] || null,
};
}
Push notifications. Send a push notification or SMS when: a trip earning is added to their balance, a payout is initiated, a payout succeeds, or a payout fails. Riders want to know the moment money moves.
Payout history. Let riders view their full payout history: date, amount, M-Pesa confirmation, and status. This is their proof of income, which they may need for loan applications, rental agreements, or KRA tax filing.
Reconciliation is how you catch problems before they become crises. Run three reconciliation checks regularly.
Daily: orders versus ledger. Every completed order should have a corresponding rider_ledger entry. Run a query that finds completed orders with no matching ledger entry. These are trips where the rider worked but was not credited. Fix them immediately.
async function findMissingEarnings() {
const missing = await db.query(
`SELECT o.id, o.rider_id, o.rider_earnings, o.created_at
FROM orders o
LEFT JOIN rider_ledger rl ON rl.order_id = o.id AND rl.type = 'earning'
WHERE o.delivery_status = 'completed'
AND o.payment_status = 'paid'
AND rl.id IS NULL`
);
return missing.rows; // Each row is a trip that needs manual earnings credit
}
Weekly: Paystack balance versus ledger totals. Sum all customer payments received (from Paystack settlements) and all rider payouts sent (from Paystack transfers). The difference should equal your platform commission minus Paystack fees. If the numbers do not match, investigate.
Monthly: rider balance audit. For each rider, sum all their ledger entries and compare against their pending_balance field. The two should match exactly. If they do not, the pending_balance cache has drifted, and you need to recalculate it from the ledger.
Fraud controls. Watch for patterns: riders marking deliveries as complete without actually delivering (GPS data mismatch), riders colluding with customers to create fake orders, and duplicate payout attempts. Log everything. Build alerts for anomalies like a rider completing 50 trips in a day (physically impossible) or a payout amount that exceeds the rider's total earnings.
For the full context on how Paystack handles M-Pesa payments and transfers in Kenya, start with our Paystack in Kenya hub guide.
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