Bonaventure OgetoBy Bonaventure Ogeto|

Building a Kenyan Delivery App with Rider Payouts

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.

Platform Economics: How Money Flows

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.

Data Model: Orders, Trips, and Rider Balances

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.

Collecting Customer Payments

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]);
}

Commission Calculation and Rider Earnings

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]);
}

Registering Riders as Paystack Transfer Recipients

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.

Daily and Weekly Batch Payouts

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.

Building the Rider Earnings Dashboard

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.

Trip Reconciliation and Financial Controls

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.

Key Takeaways

  • Customer payments and rider payouts are two separate flows. Customers pay through Paystack into your balance. Rider payouts go out through Paystack Transfers to M-Pesa wallets. Never try to route customer payments directly to riders.
  • Calculate commissions on each delivery and store them in a ledger table. Your platform takes its cut, and the remainder goes into the rider pending balance. This ledger is your financial source of truth.
  • Use Paystack Transfer Recipients to register each rider M-Pesa number once. Reuse the recipient_code for every payout to that rider.
  • Batch payouts daily or weekly rather than per-delivery. Per-delivery transfers are expensive in transaction fees and create operational noise. Riders in Kenya prefer daily payouts over weekly ones.
  • Listen to transfer.success and transfer.failed webhooks. A failed transfer means the rider did not get paid. Queue it for retry or manual review.
  • Build an earnings dashboard that riders check constantly. Show: today earnings, pending balance, last payout date and amount, and a trip-by-trip breakdown. Transparency reduces support tickets.
  • Reconcile regularly. Compare your internal ledger against Paystack settlement and transfer records. Mismatches happen and catching them early prevents financial headaches.

Frequently Asked Questions

Can I pay riders instantly after each delivery instead of batching?
Technically yes, using individual Paystack Transfers triggered by each delivery completion. But this creates high transfer volumes, each with potential fees, and makes reconciliation harder. It also means you cannot claw back earnings if a customer disputes a delivery. Daily batch payouts are the industry standard for delivery platforms in Kenya. If instant payouts are a competitive requirement, consider offering them as a premium feature with a small processing fee.
What happens if a Paystack transfer to a rider fails?
The rider pending balance remains unchanged because you only deduct after receiving the transfer.success webhook. Failed transfers stay in your payouts table with status "failed." Your operations team reviews them, fixes the issue (usually a wrong phone number or Paystack balance shortfall), and retries. The rider sees their balance intact and knows they have not been paid, which is better than showing zero balance with no M-Pesa receipt.
How do I handle refunds when a delivery goes wrong?
Refund the customer through the Paystack Refunds API. Then decide whether the rider gets paid: if the rider completed the delivery but the item was damaged by the sender, the rider still gets their earnings. If the rider lost the package, deduct from their pending balance with a penalty ledger entry. Store the business rules for each refund scenario and apply them consistently.
Do Paystack Transfers to M-Pesa work for all mobile networks in Kenya?
Paystack Transfers in Kenya primarily support M-Pesa (Safaricom). If a rider uses Airtel Money or T-Kash, check Paystack current documentation for support. If their network is not supported, the rider can provide an M-Pesa number even if their primary line is on a different network. Most Kenyan riders who do delivery work have an M-Pesa-registered Safaricom line.
How do I handle Paystack fees in the commission calculation?
You have two options. Option A: absorb Paystack fees as a cost of doing business and calculate commissions on the gross delivery fee. Option B: deduct Paystack fees from the gross amount first, then split the net between platform and rider. Option A is simpler and more transparent. Option B is more accurate financially but harder for riders to understand. Most platforms choose Option A and factor Paystack fees into their overall commission rate.

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