Bonaventure OgetoBy Bonaventure Ogeto|

Building a Kenyan Utility Bill Payment Integration

Build a utility bill payment integration by looking up the customer account with the utility provider, displaying the outstanding amount, collecting payment through Paystack via M-Pesa or card, then forwarding the payment to the utility company via Paystack Transfers to their paybill or bank account. Generate a receipt for the customer. For recurring payments, store the account number and automate monthly collection and forwarding.

Why Build a Utility Payment Platform in Kenya

Kenyans already pay utility bills. KPLC has paybill 888880. Nairobi Water has its own. Every internet provider has a payment channel. So why build another layer?

Because the current experience is fragmented. A typical Nairobi household pays:

  • Electricity (KPLC) via one paybill
  • Water (Nairobi Water or county-specific) via another
  • Internet (Safaricom Home, Zuku, Faiba, JTL) via yet another
  • Rent to the landlord (see Building a Rent Collection Platform)
  • Security (if applicable) via a different paybill or till

That is five separate payment flows, five separate sets of account numbers to remember, and no consolidated view of what you paid this month. A utility bill payment platform solves this by consolidating all utility payments into one interface, one payment history, and optionally, one automated payment run each month.

The value proposition for different users:

  • Individual customers. Pay all bills from one place. See history. Set up autopay. Get reminders before due dates.
  • Landlords and property managers. Pay shared utility bills for multiple properties. Track which units have paid and which have not. Automate the collection-and-forwarding cycle.
  • Businesses. Pay utility bills for multiple branches. Get a single report for expense tracking and accounting.

The technical model is straightforward: collect payment from the customer via Paystack (M-Pesa, card, or Pesalink), then forward that payment to the utility company via Paystack Transfers to their paybill number or bank account.

Data Model for Multi-Utility Payments

The data model needs to support multiple utility types, each with its own account number format and billing cycle.

const createUtilityProvidersTable = `
  CREATE TABLE utility_providers (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(100) NOT NULL,
    type VARCHAR(50) NOT NULL,              -- 'electricity', 'water', 'internet', 'security'
    paybill_number VARCHAR(20),
    bank_code VARCHAR(20),
    bank_account VARCHAR(30),
    paystack_recipient_code VARCHAR(50),    -- For automated forwarding
    account_number_format VARCHAR(100),     -- Regex pattern for validation
    account_number_label VARCHAR(50),       -- 'Meter Number', 'Account Number', etc.
    billing_cycle VARCHAR(20),             -- 'monthly', 'prepaid', 'quarterly'
    active BOOLEAN DEFAULT true,
    created_at TIMESTAMP DEFAULT NOW()
  );
`;

const createCustomerAccountsTable = `
  CREATE TABLE customer_accounts (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID REFERENCES users(id),
    utility_provider_id UUID REFERENCES utility_providers(id),
    account_number VARCHAR(50) NOT NULL,
    account_name VARCHAR(200),
    autopay_enabled BOOLEAN DEFAULT false,
    autopay_day INT,                        -- Day of month (1-28) for autopay
    created_at TIMESTAMP DEFAULT NOW(),
    UNIQUE(user_id, utility_provider_id, account_number)
  );
`;

const createBillsTable = `
  CREATE TABLE utility_bills (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    customer_account_id UUID REFERENCES customer_accounts(id),
    billing_period VARCHAR(20),            -- '2026-07', '2026-Q3'
    amount_due INT NOT NULL,
    due_date DATE,
    status VARCHAR(20) DEFAULT 'unpaid',   -- 'unpaid', 'paid', 'overdue', 'partial'
    paid_amount INT DEFAULT 0,
    payment_reference VARCHAR(100),
    forward_reference VARCHAR(100),        -- Transfer reference to utility
    forward_status VARCHAR(20),            -- 'pending', 'sent', 'confirmed', 'failed'
    created_at TIMESTAMP DEFAULT NOW()
  );
`;

// Seed utility providers
const providers = [
  {
    name: 'Kenya Power (KPLC)',
    type: 'electricity',
    paybill_number: '888880',
    account_number_format: '^[0-9]{7,13}$',
    account_number_label: 'Meter Number',
    billing_cycle: 'monthly',
  },
  {
    name: 'Nairobi Water',
    type: 'water',
    paybill_number: '444400',
    account_number_format: '^[0-9]{6,12}$',
    account_number_label: 'Account Number',
    billing_cycle: 'monthly',
  },
  {
    name: 'Safaricom Home Fibre',
    type: 'internet',
    paybill_number: '100200',
    account_number_format: '^[0-9A-Z]{8,15}$',
    account_number_label: 'Account Number',
    billing_cycle: 'monthly',
  },
];

The account_number_format field uses regex to validate that the customer entered a plausible account number before you try to look it up or accept a payment. A KPLC meter number is 7 to 13 digits. If someone types 4 digits, reject it before making an API call. This small validation step saves you from processing payments against invalid accounts.

Bill Lookup by Account Number

Before collecting a payment, the customer needs to see what they owe. The bill lookup flow depends on the utility provider:

  • KPLC (Kenya Power). KPLC does not have a public API for bill lookup. Options include scraping the KPLC self-service portal (fragile and against ToS), partnering with KPLC for API access (requires a business relationship), or asking the customer to enter the amount manually based on their paper or SMS bill.
  • Water companies. Similar to KPLC. Some county water companies have APIs. Many do not. Manual entry may be the fallback.
  • Internet providers. ISPs with modern billing systems often have APIs or partner portals. Safaricom, for example, has business APIs. Smaller ISPs may not.
// Bill lookup endpoint
app.post('/api/bills/lookup', async (req, res) => {
  const { providerId, accountNumber } = req.body;

  const provider = await db.query(
    'SELECT * FROM utility_providers WHERE id = $1',
    [providerId]
  );

  // Validate account number format
  const regex = new RegExp(provider[0].account_number_format);
  if (!regex.test(accountNumber)) {
    return res.status(400).json({
      error: `Invalid ${provider[0].account_number_label}. Expected format: ${provider[0].account_number_format}`,
    });
  }

  let billData;

  switch (provider[0].type) {
    case 'electricity':
      billData = await lookupKPLCBill(accountNumber);
      break;
    case 'water':
      billData = await lookupWaterBill(accountNumber, provider[0].name);
      break;
    case 'internet':
      billData = await lookupInternetBill(accountNumber, provider[0].name);
      break;
    default:
      // Fallback: ask the customer to enter the amount
      billData = { accountNumber, amount: null, requiresManualEntry: true };
  }

  res.json({
    provider: provider[0].name,
    accountNumber,
    accountName: billData.accountName || null,
    amountDue: billData.amount,
    dueDate: billData.dueDate || null,
    requiresManualEntry: billData.requiresManualEntry || false,
  });
});

// KPLC lookup (example - may require partnership)
async function lookupKPLCBill(meterNumber) {
  // If you have API access through a KPLC partnership:
  // const response = await fetch(`https://kplc-api.example.com/balance?meter=${meterNumber}`);
  // return response.json();

  // Fallback: check our cached bill records
  const cachedBill = await db.query(
    `SELECT * FROM utility_bills ub
     JOIN customer_accounts ca ON ub.customer_account_id = ca.id
     WHERE ca.account_number = $1
     AND ub.status = 'unpaid'
     ORDER BY ub.created_at DESC
     LIMIT 1`,
    [meterNumber]
  );

  if (cachedBill.length > 0) {
    return {
      accountNumber: meterNumber,
      amount: cachedBill[0].amount_due,
      dueDate: cachedBill[0].due_date,
    };
  }

  // No cached data, ask for manual entry
  return { accountNumber: meterNumber, amount: null, requiresManualEntry: true };
}

The honest reality: bill lookup in Kenya is hard. Most utility companies do not expose APIs. If you are building a serious multi-utility platform, you will need to establish partnerships with each utility provider for API access or data sharing. In the meantime, manual amount entry is a legitimate fallback. The customer knows what they owe from their paper bill or SMS notification.

Payment Collection and Forwarding to Utility Companies

The payment flow has two steps: collect from the customer, then forward to the utility. This is the core of the platform.

// Collect payment from customer
app.post('/api/bills/:billId/pay', async (req, res) => {
  const { billId } = req.params;

  const bill = await db.query(
    `SELECT ub.*, ca.account_number, ca.user_id,
            up.name as provider_name, up.type as provider_type
     FROM utility_bills ub
     JOIN customer_accounts ca ON ub.customer_account_id = ca.id
     JOIN utility_providers up ON ca.utility_provider_id = up.id
     WHERE ub.id = $1`,
    [billId]
  );

  const user = await db.query('SELECT * FROM users WHERE id = $1', [bill[0].user_id]);
  const reference = `UTL-${bill[0].provider_type.toUpperCase().slice(0, 3)}-${bill[0].account_number}-${Date.now()}`;

  const response = await fetch('https://api.paystack.co/charge', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      email: user[0].email,
      amount: bill[0].amount_due,
      currency: 'KES',
      reference,
      mobile_money: {
        phone: user[0].phone,
        provider: 'mpesa',
      },
      metadata: {
        bill_id: bill[0].id,
        account_number: bill[0].account_number,
        provider_name: bill[0].provider_name,
        provider_type: bill[0].provider_type,
        billing_period: bill[0].billing_period,
        custom_fields: [
          { display_name: 'Utility', variable_name: 'utility', value: bill[0].provider_name },
          { display_name: 'Account', variable_name: 'account', value: bill[0].account_number },
          { display_name: 'Period', variable_name: 'period', value: bill[0].billing_period },
        ],
      },
    }),
  });

  await db.insert('payments', {
    reference,
    bill_id: billId,
    amount: bill[0].amount_due,
    status: 'pending',
  });

  res.json({ status: 'pending', reference, message: 'Check your phone for the M-Pesa prompt' });
});

After the payment is confirmed via webhook, forward it to the utility:

// In webhook handler, after confirming customer payment:
async function forwardToUtility(billId) {
  const bill = await db.query(
    `SELECT ub.*, up.paybill_number, up.paystack_recipient_code,
            ca.account_number
     FROM utility_bills ub
     JOIN customer_accounts ca ON ub.customer_account_id = ca.id
     JOIN utility_providers up ON ca.utility_provider_id = up.id
     WHERE ub.id = $1`,
    [billId]
  );

  // Ensure the utility provider has a transfer recipient set up
  let recipientCode = bill[0].paystack_recipient_code;

  if (!recipientCode) {
    // Create a recipient for the utility's paybill
    const recipientRes = 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: bill[0].provider_name || 'Utility Provider',
        account_number: bill[0].paybill_number,
        bank_code: 'MPESA',
        currency: 'KES',
      }),
    });

    const recipient = await recipientRes.json();
    recipientCode = recipient.data.recipient_code;

    await db.query(
      'UPDATE utility_providers SET paystack_recipient_code = $1 WHERE paybill_number = $2',
      [recipientCode, bill[0].paybill_number]
    );
  }

  // Forward the payment
  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: bill[0].amount_due,
      recipient: recipientCode,
      reason: `Utility payment: ${bill[0].account_number}`,
    }),
  });

  const transfer = await transferRes.json();

  await db.query(
    'UPDATE utility_bills SET forward_reference = $1, forward_status = $2 WHERE id = $3',
    [transfer.data.transfer_code, 'sent', billId]
  );
}

A critical point: the amount you forward to the utility is the full bill amount. Your margin (if your platform charges a convenience fee) comes from a separate fee you charge the customer on top of the bill amount, or from a commission arrangement with the utility provider. Do not deduct your fee from the utility payment, or the customer's account will show a partial payment at the utility company.

For details on Paystack Transfers to paybill numbers, see Paystack Transfers to Paybill and Till Numbers.

Receipt Generation for Utility Payments

Receipts for utility payments serve a specific purpose: proof that the customer paid. When a customer disputes a bill with KPLC and says "I already paid," the receipt from your platform is their evidence.

async function generateUtilityReceipt(paymentData) {
  const { reference, providerName, accountNumber, amount, billingPeriod, forwardReference } = paymentData;

  const receipt = {
    receiptNumber: reference,
    date: new Date().toISOString(),
    platformName: process.env.PLATFORM_NAME,

    // Utility details
    utilityProvider: providerName,
    accountNumber: accountNumber,
    billingPeriod: billingPeriod,

    // Payment details
    amountPaid: amount,
    paymentMethod: 'M-Pesa',
    forwardReference: forwardReference,

    // Status
    paymentStatus: 'Confirmed',
    forwardStatus: 'Payment forwarded to utility provider',
  };

  return await createReceiptPDF(receipt);
}

The receipt must include:

  • The utility provider name and the customer's account number
  • The billing period the payment covers
  • The amount paid
  • The forward reference (the transfer code from Paystack) so the customer can trace the payment to the utility
  • The date and time of payment

Send the receipt via SMS with a download link. An SMS that says "KPLC payment of KES 3,500 for meter 12345678 confirmed. Receipt: UTL-ELE-12345678-1721500000" gives the customer immediate proof without needing to open an app.

Recurring Utility Payments

Recurring payments are where a utility platform earns its value. Instead of the customer remembering to pay KPLC, water, and internet separately each month, they set it up once and it happens automatically.

The challenge with M-Pesa recurring payments is that M-Pesa does not support automatic direct debits like cards do. Each payment requires the customer to authorize via STK push and enter their PIN. So "autopay" for M-Pesa means "we send you the STK push on the scheduled day, and you just need to enter your PIN."

// Scheduled job: process autopay for all eligible accounts
async function processAutopay() {
  const today = new Date().getDate(); // Day of month (1-31)

  const autopayAccounts = await db.query(
    `SELECT ca.*, up.name as provider_name, u.phone, u.email
     FROM customer_accounts ca
     JOIN utility_providers up ON ca.utility_provider_id = up.id
     JOIN users u ON ca.user_id = u.id
     WHERE ca.autopay_enabled = true
       AND ca.autopay_day = $1`,
    [today]
  );

  for (const account of autopayAccounts) {
    // Find the unpaid bill for this account
    const bill = await db.query(
      `SELECT * FROM utility_bills
       WHERE customer_account_id = $1
         AND status = 'unpaid'
       ORDER BY created_at DESC
       LIMIT 1`,
      [account.id]
    );

    if (bill.length === 0) continue; // No bill to pay

    // Send SMS notification first
    const amountKES = (bill[0].amount_due / 100).toLocaleString();
    await sendSMS(
      account.phone,
      `Autopay: Your ${account.provider_name} bill of KES ${amountKES} (account ${account.account_number}) will be charged via M-Pesa shortly. Make sure you have sufficient funds.`
    );

    // Wait a few seconds for the SMS to arrive, then send STK push
    // In production, use a delayed queue rather than setTimeout
    await delay(5000);

    // Initiate the M-Pesa charge
    const reference = `AUTO-${account.account_number}-${Date.now()}`;
    await fetch('https://api.paystack.co/charge', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        email: account.email,
        amount: bill[0].amount_due,
        currency: 'KES',
        reference,
        mobile_money: {
          phone: account.phone,
          provider: 'mpesa',
        },
        metadata: {
          bill_id: bill[0].id,
          account_number: account.account_number,
          provider_name: account.provider_name,
          autopay: true,
        },
      }),
    });

    await db.insert('payments', {
      reference,
      bill_id: bill[0].id,
      amount: bill[0].amount_due,
      status: 'pending',
    });
  }
}

Important: always notify the customer before initiating an autopay charge. The SMS should arrive before the STK push. If a customer gets an unexpected STK push from a shortcode they do not recognize, they will decline it. But if they got an SMS ten seconds earlier saying "Your KPLC autopay of KES 3,500 is coming through," they will enter their PIN.

Handle the case where the customer declines or the charge times out. The autopay is not guaranteed to succeed. Log the failure, send a follow-up SMS ("Your KPLC autopay did not go through. Pay manually at [link]."), and do not retry without the customer's knowledge.

Multi-Utility Platform Design

A multi-utility platform lets the customer link all their utility accounts and manage payments from one dashboard. The design needs to handle the differences between utility types while presenting a unified experience.

The dashboard view for a customer might show:

// API endpoint: get all bills for a user
app.get('/api/users/:userId/bills', async (req, res) => {
  const { userId } = req.params;

  const bills = await db.query(
    `SELECT ub.*, ca.account_number, up.name as provider_name,
            up.type as provider_type
     FROM utility_bills ub
     JOIN customer_accounts ca ON ub.customer_account_id = ca.id
     JOIN utility_providers up ON ca.utility_provider_id = up.id
     WHERE ca.user_id = $1
     ORDER BY ub.due_date ASC`,
    [userId]
  );

  const summary = {
    totalDue: bills.filter(b => b.status === 'unpaid').reduce((sum, b) => sum + b.amount_due, 0),
    overdue: bills.filter(b => b.status === 'overdue').length,
    upcoming: bills.filter(b => b.status === 'unpaid' && new Date(b.due_date) > new Date()).length,
  };

  res.json({ bills, summary });
});

// Pay all outstanding bills in one go
app.post('/api/users/:userId/pay-all', async (req, res) => {
  const { userId } = req.params;

  const unpaidBills = await db.query(
    `SELECT ub.*, ca.account_number, up.name as provider_name
     FROM utility_bills ub
     JOIN customer_accounts ca ON ub.customer_account_id = ca.id
     JOIN utility_providers up ON ca.utility_provider_id = up.id
     WHERE ca.user_id = $1 AND ub.status = 'unpaid'
     ORDER BY ub.due_date ASC`,
    [userId]
  );

  const totalAmount = unpaidBills.reduce((sum, b) => sum + b.amount_due, 0);

  // Collect the total amount as one M-Pesa charge
  // On success, the webhook handler splits and forwards to each utility
  const reference = `BATCH-${userId.slice(0, 8)}-${Date.now()}`;

  const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);

  await fetch('https://api.paystack.co/charge', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      email: user[0].email,
      amount: totalAmount,
      currency: 'KES',
      reference,
      mobile_money: {
        phone: user[0].phone,
        provider: 'mpesa',
      },
      metadata: {
        user_id: userId,
        payment_type: 'batch',
        bill_ids: unpaidBills.map(b => b.id),
        bill_details: unpaidBills.map(b => ({
          id: b.id,
          provider: b.provider_name,
          account: b.account_number,
          amount: b.amount_due,
        })),
      },
    }),
  });

  res.json({
    status: 'pending',
    reference,
    totalAmount,
    billCount: unpaidBills.length,
    message: 'Check your phone for the M-Pesa prompt',
  });
});

The "pay all bills" feature is a strong selling point. Instead of five separate M-Pesa transactions, the customer does one. Your webhook handler receives the batch payment and initiates separate Paystack Transfers to each utility. The customer sees one debit on their M-Pesa. Each utility receives its correct amount.

One caution: M-Pesa has per-transaction limits. If the total of all bills exceeds the limit, the customer cannot pay all at once. Handle this gracefully by falling back to individual payments or splitting into chunks that fit within M-Pesa limits.

The Alternative: Direct M-Pesa Paybill

Before you build a utility payment platform, consider why someone would use it instead of paying directly via M-Pesa paybill. Every KPLC customer already knows to dial *234# or go to M-Pesa and pay paybill 888880. Your platform adds a step in the middle.

A utility payment platform is worth building when it offers value that direct paybill does not:

  • Consolidation. Paying five utilities from one place instead of five separate M-Pesa transactions.
  • Payment history and receipts. A clear record of every utility payment, downloadable receipts, and expense tracking. M-Pesa statements exist, but they are not organized by utility.
  • Autopay. Automatic monthly payments that the customer sets up once. M-Pesa has no autopay for paybills.
  • Reminders. Notifications before bills are due, preventing late fees and disconnections.
  • Property management integration. Landlords and property managers who manage utility payments for tenants or commercial buildings need a system, not a paybill.
  • Card payments. Some customers (especially corporate ones) want to pay utilities with a company card. Paybills only accept M-Pesa.

If you cannot offer at least two or three of these benefits, the customer has no reason to switch from direct paybill. Be honest about this in your product planning. The technology is the easy part. The value proposition is what determines whether anyone actually uses it.

There is also a regulatory consideration. If your platform collects money from customers and holds it before forwarding to utilities, you may be operating as a payment intermediary. The CBK regulations have specific rules about this. Using Paystack as your licensed payment service provider helps, but you should understand the rules that apply to your specific model.

A Note on KPLC Prepaid Tokens

KPLC electricity in Kenya is either postpaid (get a monthly bill) or prepaid (buy tokens). The prepaid model is increasingly common. The customer buys tokens via M-Pesa paybill 888880, enters their meter number, and receives a token number via SMS that they enter into their meter.

If your platform handles KPLC prepaid, you need a way to get the token back to the customer after payment. This requires integration with the token vending system, which is controlled by KPLC. Without an official partnership, you cannot vend tokens.

Options:

  • Partner with KPLC. This gives you API access to vend tokens directly. This is the legitimate path but requires a business relationship.
  • Forward the payment to paybill 888880. The customer receives the token SMS from Safaricom/KPLC. You do not handle the token yourself. Your role is just facilitating the payment. The customer still gets their token from the existing system.
  • Focus on postpaid only. Avoid the token problem entirely by only supporting KPLC postpaid customers.

The forwarding approach (pay via your platform, KPLC sends the token) is the most practical for most developers. You add value through consolidation, history, and autopay without needing to integrate with the token vending infrastructure.

Learn to Build Payment Platforms

A utility payment platform combines collection, transfers, scheduling, and multi-provider integration. It is a solid intermediate project that touches many aspects of full-stack engineering.

The McTaba M-Pesa Integration course (KES 9,999) covers the payment collection and transfer patterns at the core of this system. The full fee applies as credit toward the 26-week bootcamp.

The 26-week Full-Stack Software and AI Engineering bootcamp (KES 120,000) covers the complete engineering picture: databases, API design, scheduled jobs, deployment, and building systems that run reliably in production.

Key Takeaways

  • A utility bill payment platform sits between the customer and the utility company. You collect from the customer via Paystack and forward the payment to the utility via Paystack Transfers to their paybill or bank account.
  • Bill lookup is the critical first step. The customer enters their account or meter number, your system queries the utility API (or your cached records) to retrieve the outstanding balance, and displays it before payment.
  • KPLC (Kenya Power), Nairobi Water, and internet providers each have different account number formats and billing cycles. Your system must handle each utility type with its own lookup and validation logic.
  • Receipt generation must include the utility account number, the amount paid, and the transaction reference. Customers need this proof when disputing bills with utility companies.
  • Recurring utility payments reduce friction for customers. Store the account number and preferred payment method, then collect and forward automatically each billing cycle.
  • Direct M-Pesa paybill is the alternative. If customers are already comfortable paying KPLC via paybill 888880, your platform must offer enough additional value (consolidation, history, reminders, autopay) to justify going through you instead.

Frequently Asked Questions

How does the platform forward payments to utility companies?
After collecting payment from the customer via Paystack (M-Pesa or card), the system initiates a Paystack Transfer to the utility company's paybill number or bank account. Each utility provider is set up as a transfer recipient in Paystack. The transfer happens automatically after the customer's payment is confirmed via webhook.
Can customers set up automatic monthly utility payments?
Yes, but with a caveat. M-Pesa does not support automatic direct debits. So "autopay" means the system sends an STK push on the scheduled day each month, and the customer enters their PIN. An SMS notification is sent before the STK push so the customer expects it. If the customer declines or the charge times out, a follow-up notification is sent with a manual payment link.
Can the platform handle KPLC prepaid token purchases?
The platform can collect the payment and forward it to KPLC paybill 888880. KPLC then sends the token to the customer via SMS through the standard M-Pesa/KPLC flow. To vend tokens directly (without going through the paybill), you need an API partnership with KPLC for access to the token vending system.
Is it legal to collect utility payments on behalf of customers?
You need to understand CBK regulations around payment intermediaries. Using Paystack as your licensed payment service provider is part of the compliance picture, but your specific business model may have additional requirements. If you are holding customer funds before forwarding them to utilities, you may need specific authorization. Consult with a lawyer familiar with Kenya's payment regulations.
How does the platform make money?
Common models include a convenience fee per transaction (charged to the customer on top of the bill amount), a commission arrangement with utility providers (the utility pays you for each payment collected), a subscription model for premium features (autopay, SMS reminders, payment history), or a combination. The model depends on which side of the transaction is willing to pay for the value you provide.

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