Bonaventure OgetoBy Bonaventure Ogeto|

Building a Boda Boda Rider Payout System

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.

Architecture Overview

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:

  1. Customer pays for a ride. Through your app, the customer pays via M-Pesa or card. The payment goes into your Paystack balance.
  2. Trip is recorded. Your system logs the trip details: distance, fare, rider, timestamp.
  3. Commission is calculated. Your platform takes a percentage (the commission). The rest belongs to the rider.
  4. Payout is scheduled. At the end of the day (or week), your system calculates each rider's total earnings and queues the payouts.
  5. Transfer is executed. Paystack Transfers send money from your balance to the rider's M-Pesa wallet.
  6. Reconciliation. You compare intended payouts against confirmed transfers and handle any failures.

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.

Data Model

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:

  • commission_rate per rider allows you to offer different rates. New riders might get a lower commission rate as an incentive. Top performers might negotiate better terms.
  • payout_line_items connects each payout to the specific trips it covers. When a rider disputes a payout amount, you can show them exactly which trips were included.
  • total_earned_cents and total_paid_cents on the rider give a running balance without querying the full trip and payout history.
  • payout_batches groups individual payouts into a single batch for tracking and reconciliation.

Transfer Recipient Management

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:

  • Register the recipient at onboarding, not at payout time. If you wait until payout time and the registration fails, the rider does not get paid. Register early so you have time to fix issues.
  • Handle phone number changes. Riders switch phone numbers. When a rider updates their phone, create a new transfer recipient with the new number and update your database. The old recipient_code still exists in Paystack but you stop using it.
  • Validate the phone number format. Paystack expects a specific format for M-Pesa numbers. Normalize the phone number before sending it (strip leading zeros, add country code, etc.). Check Paystack's documentation for the exact format they accept.

Commission Calculation

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

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.

Bulk Transfers for Batch Payouts

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:

  • There is a maximum number of transfers per bulk request. Check Paystack's documentation for the current limit. If you have more riders than the limit, split into multiple bulk requests.
  • Each individual transfer in the bulk request gets its own webhook. You will receive transfer.success or transfer.failed for each rider separately, not one event for the whole batch.
  • Partial failures. Some transfers in a batch can succeed while others fail. Your system must handle this. Do not assume the whole batch either succeeded or failed.
  • Unique references. Each transfer in the batch needs a unique reference. Use the payout ID as part of the reference to make it traceable.

Transfer Failure Handling and Retries

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:

  • "recipient not found" or invalid account. The phone number is wrong or deregistered. Contact the rider to update their phone number.
  • "insufficient balance." Your Paystack balance is too low. Top up your balance and retry.
  • "transfer limit exceeded." You have hit Paystack's daily or per-transaction transfer limit. Contact Paystack to increase limits or spread payouts across multiple days.
  • "service unavailable." Safaricom or Paystack is experiencing issues. Wait and retry after a few hours.

Reconciliation

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:

  • How many riders were paid today?
  • How much was paid out in total?
  • How many payouts failed and why?
  • What is the outstanding balance owed to riders?
  • Does the total collected from customers minus commissions equal the total paid out to riders (plus the failed payouts pending retry)?

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.

Deployment Notes

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.

Key Takeaways

  • Paystack Transfers send money from your Paystack balance to M-Pesa wallets, bank accounts, and paybills. For boda boda riders, M-Pesa is the only payout method that matters.
  • Create transfer recipients once for each rider. Store the recipient_code in your database. You reuse it for every subsequent payout.
  • Always check your Paystack balance before initiating payouts. If your balance is lower than the total payout amount, the transfers will fail.
  • Use the Bulk Transfer API for batch payouts (end of day or weekly). It is more efficient and easier to track than individual transfers.
  • Commission calculation must be transparent. Store the commission rate, the gross trip amount, the commission taken, and the net payout for every trip. Riders will dispute payouts if the math is not clear.
  • Transfer failures happen. M-Pesa wallets can reject transfers if the number is inactive, the wallet is full, or Safaricom has a temporary issue. Build a retry queue that tries again after a delay.
  • Reconciliation is your safety net. At the end of each payout cycle, compare what you intended to pay against what Paystack confirms was delivered.

Frequently Asked Questions

Can I use Paystack Transfers to pay riders in real time after each trip?
Technically yes, but practically this is expensive and risky. Each transfer incurs a fee, and real-time transfers mean your system has no buffer to catch errors. Batch payouts at the end of the day are more cost-effective and give you time to verify totals before disbursing. If real-time payouts are a product requirement, consider pre-funding your Paystack balance and carefully monitoring the per-transaction transfer fees.
What happens if a rider changes their M-Pesa number?
Create a new transfer recipient with the new phone number and update the paystack_recipient_code in your riders table. The old recipient still exists in Paystack but you stop sending transfers to it. Have the rider verify the new number by receiving a small test transfer (e.g., KES 1) before switching over.
How do I handle the delay between customer payment and Paystack settlement?
Paystack settles on a schedule that depends on your account type. If your settlement cycle is T+1 (next business day), money collected today is available for transfers tomorrow. For same-day payouts, pre-fund your Paystack balance with working capital from your bank account. This requires cash reserves but lets you pay riders the same day they earn.
Can riders receive payouts to a bank account instead of M-Pesa?
Yes. Create the transfer recipient with type "nuban" or the Kenyan bank equivalent instead of "mobile_money." Use the rider's bank code and account number. In practice, almost all boda boda riders prefer M-Pesa because it is instant and accessible without visiting a bank branch.
How do I handle cash trips in the payout calculation?
When a customer pays cash, the rider already has the fare. But they owe your platform the commission on that cash trip. Deduct the cash commission from their digital payout. If a rider earned KES 3,000 digitally and collected KES 1,000 in cash with KES 150 commission, the digital payout is KES 3,000 minus KES 150 = KES 2,850.

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