Bonaventure OgetoBy Bonaventure Ogeto|

Building a Hospital Bill Payment System in Kenya

Build a hospital payment system by creating a patient registry, generating itemized bills from clinical encounters, and collecting payments through Paystack via M-Pesa and cards. Handle partial payments with deposit-and-balance flows. For insured patients, calculate the co-pay amount and only charge the patient their share. Use metadata to link every payment to the patient file number, bill ID, and visit type.

How Hospital Billing Works in Kenya

A patient walks into a Kenyan hospital and the billing clock starts. At a private facility, the first question after triage is often "Cash or insurance?" That answer determines the entire payment flow.

For cash patients, the flow is relatively straightforward: see the doctor, get diagnosed, get treated, pay the bill. But even this simple path has complications. The patient might not have enough money for the full bill. They might want to pay a deposit and clear the rest later. They might pay for consultation and lab work separately as each service is rendered.

For insured patients, the hospital bills the insurance company for the covered portion and the patient pays the co-pay or excess. The hospital needs to generate documentation that the insurer will accept for reimbursement. And the patient needs a receipt that clearly shows what they paid versus what insurance covered.

For both paths, the common thread is that hospital bills are itemized, they grow over time (especially for inpatients), and patients frequently pay in installments. A payment system for a Kenyan hospital needs to handle all of this.

The payment layer uses Paystack to collect M-Pesa and card payments. But the billing logic, the insurance calculations, and the patient account management are all your responsibility. Paystack is the cash register. You build the accounting system around it.

Patient Registration and the Data Model

Every hospital has a patient registration desk. Your digital system mirrors this with a patient registry that assigns a unique file number to each patient.

// Database schema for hospital billing
const createPatientsTable = `
  CREATE TABLE patients (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    file_number VARCHAR(20) UNIQUE NOT NULL,
    first_name VARCHAR(100) NOT NULL,
    last_name VARCHAR(100) NOT NULL,
    id_number VARCHAR(20),
    phone VARCHAR(15) NOT NULL,
    email VARCHAR(200),
    date_of_birth DATE,
    insurance_provider VARCHAR(100),
    insurance_member_number VARCHAR(50),
    insurance_scheme VARCHAR(50),  -- 'NHIF', 'SHA', 'AAR', 'Jubilee', etc.
    created_at TIMESTAMP DEFAULT NOW()
  );
`;

const createBillsTable = `
  CREATE TABLE bills (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    bill_number VARCHAR(30) UNIQUE NOT NULL,
    patient_id UUID REFERENCES patients(id),
    visit_type VARCHAR(20) NOT NULL,      -- 'outpatient', 'inpatient'
    status VARCHAR(20) DEFAULT 'open',    -- 'open', 'partially_paid', 'settled', 'discharged'
    total_amount INT DEFAULT 0,           -- Accumulates as items are added
    insurance_amount INT DEFAULT 0,       -- Portion covered by insurance
    patient_liability INT DEFAULT 0,      -- What the patient owes (total - insurance)
    paid_amount INT DEFAULT 0,
    balance INT GENERATED ALWAYS AS (patient_liability - paid_amount) STORED,
    admission_date TIMESTAMP,
    discharge_date TIMESTAMP,
    created_at TIMESTAMP DEFAULT NOW()
  );
`;

const createBillItemsTable = `
  CREATE TABLE bill_items (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    bill_id UUID REFERENCES bills(id),
    service_code VARCHAR(20) NOT NULL,
    description VARCHAR(200) NOT NULL,    -- 'Consultation', 'CBC Lab Test', 'Paracetamol 500mg x 10'
    category VARCHAR(50) NOT NULL,        -- 'consultation', 'laboratory', 'pharmacy', 'radiology', 'ward'
    quantity INT DEFAULT 1,
    unit_price INT NOT NULL,              -- In cents
    total_price INT NOT NULL,             -- quantity * unit_price
    covered_by_insurance BOOLEAN DEFAULT false,
    created_at TIMESTAMP DEFAULT NOW()
  );
`;

const createPaymentsTable = `
  CREATE TABLE payments (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    reference VARCHAR(100) UNIQUE NOT NULL,
    bill_id UUID REFERENCES bills(id),
    patient_id UUID REFERENCES patients(id),
    amount INT NOT NULL,
    payment_method VARCHAR(20) NOT NULL,  -- 'mpesa', 'card', 'pesalink', 'insurance'
    payment_type VARCHAR(20) NOT NULL,    -- 'deposit', 'interim', 'final', 'copay'
    status VARCHAR(20) DEFAULT 'pending',
    completed_at TIMESTAMP,
    created_at TIMESTAMP DEFAULT NOW()
  );
`;

Design notes:

  • Bill items are additive. As the patient receives services (consultation, lab tests, pharmacy, procedures), items are added to the bill. The total_amount on the bill grows. This is especially important for inpatients whose bills accumulate over days or weeks.
  • Patient liability is separate from total. For insured patients, patient_liability = total_amount - insurance_amount. The patient only pays their share.
  • Payment type tracks the nature of each payment. A "deposit" is paid at admission. "Interim" payments happen during the stay. "Final" clears the balance at discharge. "Copay" is the patient's insurance excess.

Outpatient Payment Flow

Outpatient billing is the simpler of the two flows. The patient arrives, sees the doctor, gets tests or medication, pays, and leaves. The entire visit usually happens in one day.

// Generate a bill for an outpatient visit
app.post('/api/bills/outpatient', async (req, res) => {
  const { patientId, items } = req.body;

  const patient = await db.query('SELECT * FROM patients WHERE id = $1', [patientId]);
  const billNumber = `OPD-${patient[0].file_number}-${Date.now()}`;

  // Calculate totals
  let totalAmount = 0;
  let insuranceAmount = 0;

  const processedItems = items.map(item => {
    const itemTotal = item.quantity * item.unitPrice;
    totalAmount += itemTotal;

    // Check if this service category is covered by the patient's insurance
    const covered = patient[0].insurance_provider &&
      isServiceCovered(patient[0].insurance_scheme, item.category);
    if (covered) {
      insuranceAmount += itemTotal;
    }

    return { ...item, total_price: itemTotal, covered_by_insurance: covered };
  });

  const patientLiability = totalAmount - insuranceAmount;

  // Create the bill
  const bill = await db.insert('bills', {
    bill_number: billNumber,
    patient_id: patientId,
    visit_type: 'outpatient',
    total_amount: totalAmount,
    insurance_amount: insuranceAmount,
    patient_liability: patientLiability,
    status: patientLiability > 0 ? 'open' : 'settled',
  });

  // Add bill items
  for (const item of processedItems) {
    await db.insert('bill_items', {
      bill_id: bill.id,
      service_code: item.serviceCode,
      description: item.description,
      category: item.category,
      quantity: item.quantity,
      unit_price: item.unitPrice,
      total_price: item.total_price,
      covered_by_insurance: item.covered_by_insurance,
    });
  }

  res.json({
    billNumber,
    totalAmount,
    insuranceAmount,
    patientLiability,
    items: processedItems,
  });
});

// Collect payment for an outpatient bill
app.post('/api/bills/:billId/pay', async (req, res) => {
  const { billId } = req.params;
  const { amount } = req.body;

  const bill = await db.query('SELECT * FROM bills WHERE id = $1', [billId]);
  const patient = await db.query('SELECT * FROM patients WHERE id = $1', [bill[0].patient_id]);

  const reference = `HOSP-${bill[0].bill_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: patient[0].email || `${patient[0].file_number}@hospital.portal`,
      amount,
      currency: 'KES',
      reference,
      mobile_money: {
        phone: patient[0].phone,
        provider: 'mpesa',
      },
      metadata: {
        patient_id: patient[0].id,
        file_number: patient[0].file_number,
        bill_id: bill[0].id,
        bill_number: bill[0].bill_number,
        visit_type: 'outpatient',
        payment_type: amount >= bill[0].balance ? 'final' : 'interim',
        custom_fields: [
          { display_name: 'Patient', variable_name: 'patient', value: `${patient[0].file_number}` },
          { display_name: 'Bill', variable_name: 'bill', value: bill[0].bill_number },
        ],
      },
    }),
  });

  const data = await response.json();

  await db.insert('payments', {
    reference,
    bill_id: billId,
    patient_id: patient[0].id,
    amount,
    payment_method: 'mpesa',
    payment_type: amount >= bill[0].balance ? 'final' : 'interim',
    status: 'pending',
  });

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

Notice what is NOT in the metadata: no patient name, no diagnosis, no treatment details. The file number is enough to look up the patient in your system. Paystack metadata is visible in their dashboard and in transaction exports. Medical information does not belong there. This is a privacy requirement under the Kenya Data Protection Act, not just good practice.

Inpatient Billing: Deposits, Running Bills, and Discharge

Inpatient billing is where the complexity lives. The patient is admitted, a deposit is collected, services accumulate daily (ward charges, meals, medications, procedures, nursing care), and the bill is settled at discharge.

The flow has three payment stages:

  1. Admission deposit. The hospital requires a deposit before or at admission. This is typically a fixed amount based on the ward type. The deposit is a payment against a bill that has not been fully generated yet.
  2. Interim payments. If the stay is long, the hospital may request additional payments when the running bill exceeds the deposit significantly. Some hospitals send daily or weekly interim bills.
  3. Discharge settlement. At discharge, the final bill is calculated. The deposit and any interim payments are subtracted. The patient pays the remaining balance before leaving.
// Admission: create bill and collect deposit
app.post('/api/admissions', async (req, res) => {
  const { patientId, wardType, estimatedDays, depositAmount } = req.body;

  const patient = await db.query('SELECT * FROM patients WHERE id = $1', [patientId]);
  const billNumber = `INP-${patient[0].file_number}-${Date.now()}`;

  // Create the inpatient bill
  const bill = await db.insert('bills', {
    bill_number: billNumber,
    patient_id: patientId,
    visit_type: 'inpatient',
    total_amount: 0,  // Will grow as services are added
    insurance_amount: 0,
    patient_liability: 0,
    status: 'open',
    admission_date: new Date(),
  });

  // Add the initial ward charge estimate (for the deposit calculation)
  // Actual charges are added daily

  // Collect the admission deposit via M-Pesa
  const reference = `DEP-${billNumber}-${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: patient[0].email || `${patient[0].file_number}@hospital.portal`,
      amount: depositAmount,
      currency: 'KES',
      reference,
      mobile_money: {
        phone: patient[0].phone,
        provider: 'mpesa',
      },
      metadata: {
        patient_id: patient[0].id,
        file_number: patient[0].file_number,
        bill_id: bill.id,
        bill_number: billNumber,
        visit_type: 'inpatient',
        payment_type: 'deposit',
        custom_fields: [
          { display_name: 'File No.', variable_name: 'file_number', value: patient[0].file_number },
          { display_name: 'Bill', variable_name: 'bill', value: billNumber },
          { display_name: 'Type', variable_name: 'type', value: 'Admission Deposit' },
        ],
      },
    }),
  });

  await db.insert('payments', {
    reference,
    bill_id: bill.id,
    patient_id: patientId,
    amount: depositAmount,
    payment_method: 'mpesa',
    payment_type: 'deposit',
    status: 'pending',
  });

  res.json({
    billNumber,
    depositAmount,
    reference,
    message: 'Admission deposit initiated. Check phone for M-Pesa prompt.',
  });
});

// Daily job: add ward charges for all inpatients
async function addDailyWardCharges() {
  const openBills = await db.query(
    "SELECT b.*, p.insurance_scheme FROM bills b JOIN patients p ON b.patient_id = p.id WHERE b.visit_type = 'inpatient' AND b.status IN ('open', 'partially_paid')"
  );

  for (const bill of openBills) {
    // Get ward rate based on ward type
    const wardRate = await getWardDailyRate(bill.id);

    await db.insert('bill_items', {
      bill_id: bill.id,
      service_code: 'WARD-DAY',
      description: `Ward charge - ${new Date().toLocaleDateString('en-KE')}`,
      category: 'ward',
      quantity: 1,
      unit_price: wardRate,
      total_price: wardRate,
      covered_by_insurance: isServiceCovered(bill.insurance_scheme, 'ward'),
    });

    // Recalculate bill totals
    await recalculateBillTotals(bill.id);
  }
}

The recalculateBillTotals function sums all bill items, recalculates the insurance portion, and updates the patient liability and balance. Run this every time a bill item is added, not just once a day.

Insurance Co-Pay Handling

Insurance complicates billing because the hospital is dealing with two payers: the insurance company and the patient. The hospital bills the insurer separately (through the insurer's portal or claims process). The patient pays only their co-pay or the uncovered portion through your payment system.

The logic depends on the insurance type:

  • NHIF/SHA. The Social Health Authority (which replaced NHIF) covers specific services at specific rates. For example, SHA might cover inpatient stays at a daily rate cap. If the hospital charges more than the SHA rate, the patient pays the difference. Your system needs the SHA rate card to calculate the patient's portion.
  • Private insurance (AAR, Jubilee, CIC, Madison, etc.). Each insurer has its own scheme rules. Some cover 80% of approved costs. Some have fixed copay amounts (e.g., patient pays KES 200 per outpatient visit). Some have annual limits after which the patient pays everything.
  • Corporate schemes. Companies may have custom schemes with their own rules. The HR department pre-authorizes treatment, and the company is billed directly.
// Calculate patient liability for an insured patient
function calculatePatientLiability(billItems, insuranceScheme) {
  let totalBill = 0;
  let insuranceCover = 0;

  for (const item of billItems) {
    totalBill += item.total_price;

    if (!item.covered_by_insurance) continue;

    if (insuranceScheme.type === 'sha') {
      // SHA covers up to a rate cap per service category
      const shaRate = getSHARateCap(item.category);
      const covered = Math.min(item.total_price, shaRate * item.quantity);
      insuranceCover += covered;
    } else if (insuranceScheme.type === 'percentage') {
      // Private insurer covers X% of approved costs
      insuranceCover += Math.floor(item.total_price * insuranceScheme.coveragePercent / 100);
    } else if (insuranceScheme.type === 'fixed_copay') {
      // Patient pays a fixed amount, insurer covers the rest
      // Fixed copay is per visit, not per item
      insuranceCover = totalBill - insuranceScheme.copayAmount;
    }
  }

  // Apply annual limit if applicable
  if (insuranceScheme.annualLimit) {
    const usedThisYear = insuranceScheme.usedAmount || 0;
    const remainingLimit = insuranceScheme.annualLimit - usedThisYear;
    insuranceCover = Math.min(insuranceCover, remainingLimit);
  }

  const patientLiability = totalBill - insuranceCover;

  return {
    totalBill,
    insuranceCover,
    patientLiability: Math.max(0, patientLiability),
  };
}

A warning: insurance calculations in your system are estimates until the insurer actually approves the claim. The insurer might reject certain line items or apply different rates than you expected. Build your system so that the insurance amount on a bill can be adjusted after the fact. When a claim is partially rejected, the patient liability increases and you may need to collect the difference.

For receipts destined for insurance claims, include the specific fields the insurer requires: member number, scheme name, pre-authorization number (if applicable), diagnosis codes, and a breakdown of services rendered. A receipt that just says "Hospital services: KES 50,000" will be rejected by every insurer.

Webhook Handler for Hospital Payments

The webhook handler for hospital payments follows the same pattern as any Paystack webhook, but the business logic on payment success is more involved because you need to update the bill, track the payment type, and potentially trigger discharge workflows.

app.post('/webhooks/paystack', express.raw({ type: 'application/json' }), async (req, res) => {
  const hash = crypto
    .createHmac('sha512', process.env.PAYSTACK_SECRET_KEY)
    .update(req.body)
    .digest('hex');

  if (hash !== req.headers['x-paystack-signature']) {
    return res.status(401).send('Invalid signature');
  }

  res.status(200).send('OK');

  const event = JSON.parse(req.body);

  if (event.event === 'charge.success') {
    const { reference, amount, metadata } = event.data;

    // Idempotency check
    const existing = await db.query(
      'SELECT id FROM payments WHERE reference = $1 AND status = $2',
      [reference, 'completed']
    );
    if (existing.length > 0) return;

    // Update payment record
    await db.query(
      'UPDATE payments SET status = $1, completed_at = NOW() WHERE reference = $2',
      ['completed', reference]
    );

    // Update the bill
    await db.query(
      `UPDATE bills
       SET paid_amount = paid_amount + $1,
           status = CASE
             WHEN paid_amount + $1 >= patient_liability THEN 'settled'
             ELSE 'partially_paid'
           END
       WHERE id = $2`,
      [amount, metadata.bill_id]
    );

    // Generate receipt
    await generateHospitalReceipt({
      reference,
      fileNumber: metadata.file_number,
      billNumber: metadata.bill_number,
      amount,
      paymentType: metadata.payment_type,
    });

    // Send SMS to patient
    const patient = await db.query('SELECT * FROM patients WHERE id = $1', [metadata.patient_id]);
    const amountKES = (amount / 100).toLocaleString();
    await sendSMS(
      patient[0].phone,
      `Payment of KES ${amountKES} received. Bill: ${metadata.bill_number}. Receipt: ${reference}. Thank you.`
    );

    // Check if this was a discharge settlement
    if (metadata.payment_type === 'final') {
      const bill = await db.query('SELECT * FROM bills WHERE id = $1', [metadata.bill_id]);
      if (bill[0].balance <= 0) {
        // Mark as ready for discharge
        await db.query(
          'UPDATE bills SET status = $1, discharge_date = NOW() WHERE id = $2',
          ['discharged', metadata.bill_id]
        );
        // Notify the ward/nursing station
        await notifyWard(metadata.bill_id, 'Patient cleared for discharge');
      }
    }
  }
});

The discharge trigger is an important detail. In many Kenyan hospitals, a patient cannot leave until the bill is cleared. Your system should notify the ward or nursing station the moment the final payment is confirmed. This removes the manual process where the cashier prints a clearance slip and the patient walks it to the ward.

Receipt Generation for Insurance Claims

Hospital receipts serve two purposes: proof of payment for the patient, and documentation for insurance claims. A receipt that works for both needs specific fields.

async function generateHospitalReceipt(paymentData) {
  const { reference, fileNumber, billNumber, amount, paymentType } = paymentData;

  const bill = await db.query(
    `SELECT b.*, p.first_name, p.last_name, p.insurance_provider,
            p.insurance_member_number, p.insurance_scheme
     FROM bills b
     JOIN patients p ON b.patient_id = p.id
     WHERE b.bill_number = $1`,
    [billNumber]
  );

  const billItems = await db.query(
    'SELECT * FROM bill_items WHERE bill_id = $1 ORDER BY category, created_at',
    [bill[0].id]
  );

  // Group items by category for the receipt
  const categories = {};
  for (const item of billItems) {
    if (!categories[item.category]) categories[item.category] = [];
    categories[item.category].push(item);
  }

  // Receipt content structure
  const receipt = {
    receiptNumber: reference,
    date: new Date().toISOString(),
    hospitalName: process.env.HOSPITAL_NAME,
    hospitalAddress: process.env.HOSPITAL_ADDRESS,

    // Patient information (limited for privacy)
    patientFileNumber: fileNumber,
    patientName: `${bill[0].first_name} ${bill[0].last_name}`,

    // Insurance details (if applicable)
    insuranceProvider: bill[0].insurance_provider,
    memberNumber: bill[0].insurance_member_number,
    scheme: bill[0].insurance_scheme,

    // Bill summary
    billNumber: billNumber,
    visitType: bill[0].visit_type,
    serviceBreakdown: categories,
    totalBill: bill[0].total_amount,
    insurancePortion: bill[0].insurance_amount,
    patientPortion: bill[0].patient_liability,

    // Payment details
    paymentType,
    amountPaid: amount,
    previousPayments: bill[0].paid_amount - amount,
    remainingBalance: bill[0].balance,
  };

  // Generate PDF and store
  return await createReceiptPDF(receipt);
}

For SHA/NHIF claims, the receipt should include:

  • Patient member number and dependent relationship (if applicable)
  • Itemized services with category codes
  • The SHA rate applied vs the hospital rate (showing the difference the patient paid)
  • Pre-authorization reference (for inpatient admissions)

For private insurer claims, include the scheme name, policy number, and itemized services that match the insurer's service coding system. Some insurers have their own claim portals where you submit claims electronically. Your system should generate the data in a format those portals accept.

Integration with Hospital Management Systems

The payment system does not exist in isolation. It connects to the hospital's clinical and administrative systems. In a Kenyan context, common integration points include:

  • Patient registration (HMIS). When a patient registers at the front desk, their record is created in the hospital management information system. Your payment module reads from this same patient registry. Do not create a parallel patient database.
  • Clinical encounter logging. When a doctor orders a lab test or prescribes medication, that creates a bill item. The clinical system pushes items to your billing module via an internal API or a shared database.
  • Pharmacy dispensing. The pharmacy system should confirm that the patient has paid for (or the bill includes) the prescribed medication before dispensing. This requires real-time communication between the billing and pharmacy modules.
  • Laboratory and radiology. Same pattern as pharmacy. The lab should confirm payment or insurance authorization before running tests.
  • Insurance pre-authorization. For inpatient admissions, many insurers require pre-authorization. Your system should capture the pre-auth number and associate it with the bill. This number goes on the claim.

If the hospital is using an existing HMIS (KenyaEMR, IQCare, or a commercial system), your Paystack integration layer should plug into it, not replace it. Build the payment module as a service that the HMIS calls when a bill needs to be paid, and that notifies the HMIS when a payment is confirmed.

// Example: HMIS notifies payment module of a new bill item
app.post('/api/billing/add-item', async (req, res) => {
  const { billId, serviceCode, description, category, quantity, unitPrice } = req.body;

  // Add the item to the bill
  await db.insert('bill_items', {
    bill_id: billId,
    service_code: serviceCode,
    description,
    category,
    quantity,
    unit_price: unitPrice,
    total_price: quantity * unitPrice,
  });

  // Recalculate bill totals and insurance coverage
  await recalculateBillTotals(billId);

  const updatedBill = await db.query('SELECT * FROM bills WHERE id = $1', [billId]);

  res.json({ bill: updatedBill[0] });
});

The key principle: the clinical system decides what services were rendered. The billing system calculates what is owed. The payment system collects the money. Keep these concerns separated.

Privacy and Data Protection for Medical Payments

Medical payment data is sensitive. The Kenya Data Protection Act 2019 classifies health data as "sensitive personal data" that requires explicit consent and heightened protection. Beyond the legal requirement, there is a practical reason: you do not want a payment receipt or a Paystack dashboard export to reveal a patient's diagnosis.

Rules for medical payment data:

  • Paystack metadata: no clinical information. Do not put diagnosis codes, treatment descriptions, or doctor names in the metadata. Use the file number and bill number as identifiers. Your system can look up the clinical details internally when needed.
  • Payment references: opaque. A reference like HOSP-INP-2025001-1721500000 reveals that this is an inpatient bill, but not what the patient was treated for. A reference like HIV-TEST-JOHN-KAMAU is a privacy violation.
  • SMS confirmations: generic. The SMS says "Payment of KES X received for Bill Y." It does not say "Payment for dialysis received." The patient's phone might be seen by others.
  • Receipt storage: encrypted at rest. Receipts that contain itemized service descriptions should be stored encrypted. Access should be logged.
  • Dashboard access: role-based. The cashier sees payment status. The billing clerk sees itemized bills. The doctor sees clinical records. Nobody should have access to everything unless their role requires it.

Paystack handles card data securely (they are PCI DSS compliant). But the patient data in your system, the billing records, the insurance details, and the clinical-to-billing mapping are your responsibility. Encrypt sensitive fields, use HTTPS everywhere, and implement access controls that match the sensitivity of the data.

For more on regulatory requirements that affect payment systems in Kenya, see CBK Payment Service Provider Rules Developers Should Know.

Learn to Build Healthcare Payment Systems

A hospital billing system combines payment integration with database design, business logic, security, and integration architecture. It is one of the more complex builds a Kenyan developer will encounter, and it is in constant demand.

The McTaba M-Pesa Integration course (KES 9,999) covers the payment collection patterns you need: STK push, webhooks, verification, and reconciliation. 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 full stack: databases, APIs, authentication, deployment, and building production systems that handle sensitive data and real money.

Key Takeaways

  • Use the patient file number (or outpatient number) as the primary identifier in your payment system. Every bill and payment ties back to a specific patient visit.
  • Hospital bills are inherently partial-payment systems. Patients pay deposits on admission and clear balances at discharge. Your system must track deposit, interim payments, and final settlement.
  • Insurance co-pay logic sits between bill generation and payment collection. Calculate the insurer portion, subtract it, and only charge the patient for their share through Paystack.
  • Inpatient and outpatient billing follow different flows. Outpatient is pay-per-visit. Inpatient involves an admission deposit, running bill that grows daily, and a discharge settlement.
  • Paystack metadata must include the patient file number, bill ID, and visit type. This is what links a payment to the correct patient account in your hospital management system.
  • Kenya Data Protection Act 2019 applies to medical payment data. Patient names, diagnoses, and treatment details should never appear in Paystack metadata or payment references. Use opaque identifiers.
  • Receipts must be structured for insurance claims. NHIF/SHA and private insurers require specific information on receipts to process reimbursements.

Frequently Asked Questions

Can a patient pay a hospital bill in installments via M-Pesa?
Yes. The system tracks partial payments against the total bill. Each M-Pesa payment updates the paid_amount on the bill. The patient can make as many partial payments as needed until the balance reaches zero. Each payment generates its own receipt, and the bill status moves from "open" to "partially_paid" to "settled."
How do I handle insurance co-payments through Paystack?
Calculate the insurance portion based on the patient's scheme rules (percentage coverage, fixed copay, or SHA rate caps). Subtract the insurance amount from the total bill to get the patient liability. Only charge the patient for their portion through Paystack. Bill the insurer separately through their claims process. Keep these as separate tracks in your billing system.
Should I include patient diagnosis information in Paystack metadata?
No. The Kenya Data Protection Act 2019 classifies health data as sensitive personal data. Paystack metadata appears in dashboard exports and transaction logs. Use the patient file number and bill number as identifiers in metadata. Your internal system can look up clinical details when needed without exposing them through the payment layer.
How does inpatient billing differ from outpatient billing in this system?
Outpatient billing is a single-visit, single-bill flow. The patient pays and leaves. Inpatient billing is ongoing: a deposit is collected at admission, charges accumulate daily (ward fees, medications, procedures), interim payments may be requested, and the final balance is settled at discharge. The bill grows over time, and the system must support multiple payments against the same growing bill.
Can the system handle both NHIF/SHA and private insurance?
Yes. The insurance calculation logic is parameterized by scheme type. SHA has rate caps per service category. Private insurers may cover a percentage, apply a fixed copay, or have annual limits. Store the scheme rules in a configuration table and apply the appropriate calculation based on the patient's registered scheme. The system should also handle uninsured (cash) patients where the full bill is the patient's liability.

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