Bonaventure OgetoBy Bonaventure Ogeto|

Building an Event Ticketing Platform for Kenyan Events

Build a Kenyan event ticketing platform by creating events with multiple ticket types (early bird, regular, VIP), accepting payments through Paystack with M-Pesa as the default channel, generating QR code tickets delivered via SMS, implementing a check-in system that scans QR codes, managing capacity in real time, handling refunds through the Paystack Refunds API, and splitting revenue between organizers using Paystack Transfer Recipients and the Transfers API.

Why Kenya Needs a Locally-Built Ticketing Platform

Eventbrite does not support M-Pesa. Ticketmaster does not operate in Kenya. The ticketing platforms that do exist locally often have clunky payment flows, no API for custom integrations, and settlement delays that frustrate event organizers.

The opportunity is clear. Kenya hosts thousands of events monthly: tech meetups, gospel concerts, sports tournaments, comedy shows, weddings, corporate conferences, and community fundraisers. Most ticket sales happen through bank transfers, M-Pesa direct-to-organizer, or cash at the door. All three methods create reconciliation nightmares and leave organizers guessing how many people will show up.

A well-built ticketing platform needs to solve four problems. First, accept payment the way Kenyans actually pay (M-Pesa first, cards second). Second, issue verifiable tickets instantly after payment. Third, track attendance at the door. Fourth, settle funds to organizers quickly and transparently.

Paystack gives you the payment layer. You build everything else: event management, ticket generation, check-in, capacity tracking, and revenue splitting. This guide covers the full architecture.

Data Model: Events, Ticket Types, and Orders

Your database needs four core tables to start.

Events table. Stores the event itself: name, description, venue, date and time, cover image URL, organizer ID (foreign key to your users table), status (draft, published, cancelled), and a max_capacity integer. Each event belongs to one primary organizer but can have multiple co-organizers linked through a separate table.

Ticket types table. Each event has one or more ticket types. A concert might have early bird (KES 500, limited to 200), regular (KES 1,000), and VIP (KES 3,000, limited to 50). Each ticket type record stores: event_id, name, price (in the smallest currency unit, so KES 500 becomes 50000 for Paystack), quantity_available, quantity_sold, sale_start_date, sale_end_date, and description.

Orders table. When a customer initiates a checkout, create an order record: order_id, customer_email, customer_phone, ticket_type_id, quantity, total_amount, paystack_reference, status (pending, paid, refunded, cancelled), and timestamps. The paystack_reference links this order to the Paystack transaction.

Tickets table. Individual tickets issued after payment. Each row stores: ticket_id, order_id, ticket_type_id, qr_code_data (a unique string you generate), status (valid, used, invalidated), checked_in_at timestamp, and the attendee details. One order for 3 tickets creates 3 rows here.

// Simplified schema
const createTablesSQL = `
  CREATE TABLE events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    organizer_id UUID REFERENCES users(id),
    name TEXT NOT NULL,
    description TEXT,
    venue TEXT NOT NULL,
    event_date TIMESTAMPTZ NOT NULL,
    max_capacity INT,
    status TEXT DEFAULT 'draft',
    created_at TIMESTAMPTZ DEFAULT NOW()
  );

  CREATE TABLE ticket_types (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    event_id UUID REFERENCES events(id),
    name TEXT NOT NULL,
    price INT NOT NULL,  -- in cents (smallest unit)
    quantity_available INT NOT NULL,
    quantity_sold INT DEFAULT 0,
    sale_start TIMESTAMPTZ,
    sale_end TIMESTAMPTZ
  );

  CREATE TABLE orders (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    customer_email TEXT,
    customer_phone TEXT NOT NULL,
    ticket_type_id UUID REFERENCES ticket_types(id),
    quantity INT NOT NULL,
    total_amount INT NOT NULL,
    paystack_reference TEXT UNIQUE,
    status TEXT DEFAULT 'pending',
    created_at TIMESTAMPTZ DEFAULT NOW()
  );

  CREATE TABLE tickets (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    order_id UUID REFERENCES orders(id),
    ticket_type_id UUID REFERENCES ticket_types(id),
    qr_code_data TEXT UNIQUE NOT NULL,
    attendee_name TEXT,
    attendee_phone TEXT,
    status TEXT DEFAULT 'valid',
    checked_in_at TIMESTAMPTZ
  );
`;

This structure separates the concept of an order (the purchase) from individual tickets (the access tokens). One order can produce multiple tickets, and each ticket has its own QR code and check-in state.

M-Pesa-First Checkout with Paystack

When a Kenyan customer clicks "Buy Ticket," your checkout should default to M-Pesa. Do not make them scroll through a list of payment options to find it. Put M-Pesa at the top. Cards and other methods go below.

Use Paystack's Initialize Transaction endpoint with the channels parameter to control which payment methods appear and in what order.

const axios = require('axios');

async function initializeTicketPurchase(order) {
  const response = await axios.post(
    'https://api.paystack.co/transaction/initialize',
    {
      email: order.customer_email || `${order.customer_phone}@tickets.yourapp.com`,
      amount: order.total_amount, // already in smallest unit
      reference: order.paystack_reference,
      currency: 'KES',
      channels: ['mobile_money', 'card'],
      callback_url: `https://yourapp.com/tickets/confirm?ref=${order.paystack_reference}`,
      metadata: {
        order_id: order.id,
        event_id: order.event_id,
        ticket_type: order.ticket_type_name,
        quantity: order.quantity,
        custom_fields: [
          {
            display_name: 'Event',
            variable_name: 'event_name',
            value: order.event_name,
          },
          {
            display_name: 'Ticket Type',
            variable_name: 'ticket_type',
            value: order.ticket_type_name,
          },
        ],
      },
    },
    {
      headers: {
        Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
        'Content-Type': 'application/json',
      },
    }
  );

  return response.data.data.authorization_url;
}

The email problem. Paystack requires an email address for every transaction, but many Kenyan event ticket buyers do not have one or do not remember it. The practical solution: generate a placeholder email from their phone number (like 0712345678@tickets.yourapp.com). This satisfies the API requirement while keeping the phone number as your real identifier for SMS delivery.

Holding capacity. When a customer initiates checkout, temporarily reserve their tickets by decrementing quantity_available. If payment is not confirmed within 15 minutes (Paystack's session typically expires in 24 hours, but you want tighter control for popular events), release the hold. Use a cron job or scheduled task to clean up expired holds.

// Reserve tickets atomically
async function reserveTickets(ticketTypeId, quantity) {
  const result = await db.query(
    `UPDATE ticket_types
     SET quantity_available = quantity_available - $1
     WHERE id = $2
       AND quantity_available >= $1
     RETURNING quantity_available`,
    [quantity, ticketTypeId]
  );

  if (result.rowCount === 0) {
    throw new Error('Not enough tickets available');
  }

  return result.rows[0];
}

The WHERE clause quantity_available >= $1 prevents overselling at the database level. If two customers try to buy the last ticket at the same time, only one UPDATE will succeed.

QR Code Ticket Generation and SMS Delivery

Generate tickets only after you receive the charge.success webhook from Paystack and verify the transaction server-side. Never generate tickets on the redirect callback alone.

Each ticket gets a unique QR code. The QR code encodes a signed string that your check-in app can verify without hitting the database for every scan (though you should still update the database).

const crypto = require('crypto');
const QRCode = require('qrcode');

function generateTicketPayload(ticketId, eventId) {
  const data = `${ticketId}:${eventId}:${Date.now()}`;
  const signature = crypto
    .createHmac('sha256', process.env.TICKET_SECRET)
    .update(data)
    .digest('hex')
    .substring(0, 12);

  return `${data}:${signature}`;
}

async function createTicketsForOrder(order, eventId) {
  const tickets = [];

  for (let i = 0; i < order.quantity; i++) {
    const ticketId = generateUUID();
    const qrPayload = generateTicketPayload(ticketId, eventId);
    const qrImageBuffer = await QRCode.toBuffer(qrPayload, {
      width: 300,
      margin: 2,
      color: { dark: '#153564' },
    });

    // Store ticket in database
    await db.query(
      `INSERT INTO tickets (id, order_id, ticket_type_id, qr_code_data, attendee_phone, status)
       VALUES ($1, $2, $3, $4, $5, 'valid')`,
      [ticketId, order.id, order.ticket_type_id, qrPayload, order.customer_phone]
    );

    // Upload QR image to storage (S3, Supabase Storage, etc.)
    const qrUrl = await uploadToStorage(qrImageBuffer, `tickets/${ticketId}.png`);

    tickets.push({ ticketId, qrPayload, qrUrl });
  }

  return tickets;
}

SMS delivery. After generating tickets, send them via SMS. Use Africa's Talking, Twilio, or any SMS provider that works in Kenya. The SMS should include: the event name, date, venue, ticket type, and a short URL to view the ticket with its QR code.

async function sendTicketSMS(phone, eventName, ticketUrl) {
  // Using Africa's Talking as an example
  const africastalking = require('africastalking')({
    apiKey: process.env.AT_API_KEY,
    username: process.env.AT_USERNAME,
  });

  await africastalking.SMS.send({
    to: [phone],
    message: `Your ticket for ${eventName} is confirmed! View your ticket and QR code here: ${ticketUrl} Show this QR code at the entrance.`,
    from: process.env.AT_SENDER_ID,
  });
}

Keep the SMS under 160 characters if possible to avoid splitting into multiple messages (which costs more). Use a URL shortener for the ticket link. The ticket page itself should be mobile-optimized since most Kenyans will open it on a phone.

Building the Check-In System

The check-in system is the moment of truth. It needs to work fast, handle poor connectivity, and prevent duplicate entries.

QR code scanning. Build a simple web app (a PWA works well) that uses the device camera to scan QR codes. The scanner reads the QR payload, verifies the signature locally, then calls your API to mark the ticket as used.

// Check-in API endpoint
app.post('/api/check-in', authenticateStaff, async (req, res) => {
  const { qrData } = req.body;

  // Verify QR signature locally first
  const parts = qrData.split(':');
  if (parts.length !== 4) {
    return res.status(400).json({ valid: false, message: 'Invalid ticket format' });
  }

  const [ticketId, eventId, timestamp, signature] = parts;
  const expectedSig = crypto
    .createHmac('sha256', process.env.TICKET_SECRET)
    .update(`${ticketId}:${eventId}:${timestamp}`)
    .digest('hex')
    .substring(0, 12);

  if (signature !== expectedSig) {
    return res.status(400).json({ valid: false, message: 'Invalid ticket signature' });
  }

  // Check database and mark as used
  const result = await db.query(
    `UPDATE tickets
     SET status = 'used', checked_in_at = NOW()
     WHERE id = $1 AND status = 'valid'
     RETURNING *`,
    [ticketId]
  );

  if (result.rowCount === 0) {
    // Check if already used
    const existing = await db.query('SELECT status, checked_in_at FROM tickets WHERE id = $1', [ticketId]);
    if (existing.rows[0]?.status === 'used') {
      return res.status(409).json({
        valid: false,
        message: `Already checked in at ${existing.rows[0].checked_in_at}`,
      });
    }
    return res.status(404).json({ valid: false, message: 'Ticket not found or invalidated' });
  }

  const ticket = result.rows[0];
  res.json({
    valid: true,
    attendee: ticket.attendee_name || ticket.attendee_phone,
    ticketType: ticket.ticket_type_name,
  });
});

Offline mode. Venues in Kenya do not always have reliable internet. Your check-in app should cache a list of valid ticket IDs before the event starts. When offline, verify the QR signature locally and check against the cached list. Queue the check-in records and sync them to the server when connectivity returns.

Capacity dashboard. Show organizers a live dashboard during the event: total tickets sold, total checked in, remaining, and a breakdown by ticket type. This helps them make real-time decisions about things like opening up more VIP sections or calling in additional security.

Handling Refunds

Event cancellations, date changes, and customer requests all require refunds. Use the Paystack Refunds API to process them programmatically.

async function refundTicketOrder(orderId) {
  const order = await db.query('SELECT * FROM orders WHERE id = $1', [orderId]);
  if (!order.rows[0] || order.rows[0].status !== 'paid') {
    throw new Error('Order not found or not eligible for refund');
  }

  const { paystack_reference, total_amount, ticket_type_id, quantity } = order.rows[0];

  // Verify the transaction on Paystack first
  const verification = await axios.get(
    `https://api.paystack.co/transaction/verify/${paystack_reference}`,
    { headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` } }
  );

  if (verification.data.data.status !== 'success') {
    throw new Error('Original transaction was not successful');
  }

  // Process refund through Paystack
  const refund = await axios.post(
    'https://api.paystack.co/refund',
    {
      transaction: paystack_reference,
      amount: total_amount, // full refund; use a smaller amount for partial
    },
    { headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` } }
  );

  // Update order and tickets in a transaction
  await db.query('BEGIN');
  try {
    await db.query('UPDATE orders SET status = $1 WHERE id = $2', ['refunded', orderId]);
    await db.query('UPDATE tickets SET status = $1 WHERE order_id = $2', ['invalidated', orderId]);
    await db.query(
      'UPDATE ticket_types SET quantity_sold = quantity_sold - $1, quantity_available = quantity_available + $1 WHERE id = $2',
      [quantity, ticket_type_id]
    );
    await db.query('COMMIT');
  } catch (err) {
    await db.query('ROLLBACK');
    throw err;
  }

  return refund.data;
}

Three things happen in that refund flow: the money goes back to the customer through Paystack, the tickets are invalidated so they cannot be used at the door, and the capacity is restored so those tickets can be sold to someone else.

Refund policies. Define clear policies per event: full refund up to 7 days before the event, 50% refund up to 3 days before, no refund after that. Store these rules in your ticket_types or events table and enforce them in your refund endpoint. Organizers appreciate automated enforcement because it removes them from awkward conversations with ticket holders.

M-Pesa refund timing. Refunds to M-Pesa wallets through Paystack are not instant. They can take a few hours to a few business days depending on the payment channel and Paystack's settlement cycle. Set customer expectations in the refund confirmation SMS.

Split Payments for Multi-Organizer Events

Many Kenyan events have multiple stakeholders: the promoter, the venue, the artist, the sound engineer. After each ticket sale, money needs to be divided. Paystack does not have a built-in split payment feature for Kenya at the time of writing, but you can build the same result using Transfer Recipients and the Transfers API.

The approach. All ticket payments land in your Paystack balance. After settlement, you calculate each party's share and disburse using Paystack Transfers (which supports M-Pesa wallet payouts in Kenya).

// Define revenue splits for an event
const eventSplits = {
  event_id: 'evt_123',
  splits: [
    { recipient_code: 'RCP_organizer1', percentage: 50, label: 'Main Organizer' },
    { recipient_code: 'RCP_venue', percentage: 30, label: 'Venue' },
    { recipient_code: 'RCP_artist', percentage: 20, label: 'Artist' },
  ],
};

async function distributeEventRevenue(eventId) {
  const event = await getEventWithSplits(eventId);
  const totalRevenue = await getSettledRevenueForEvent(eventId);

  // Deduct platform fee first (your commission)
  const platformFee = Math.floor(totalRevenue * 0.05); // 5% platform cut
  const distributable = totalRevenue - platformFee;

  const transfers = event.splits.map((split) => ({
    source: 'balance',
    amount: Math.floor(distributable * (split.percentage / 100)),
    recipient: split.recipient_code,
    reason: `Revenue share for ${event.name} - ${split.label}`,
    reference: `split_${eventId}_${split.recipient_code}_${Date.now()}`,
  }));

  // Execute transfers using Paystack Bulk Transfers
  const response = await axios.post(
    'https://api.paystack.co/transfer/bulk',
    { currency: 'KES', source: 'balance', transfers },
    { headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` } }
  );

  return response.data;
}

Creating transfer recipients. Before you can pay organizers, register each one as a Paystack Transfer Recipient. For M-Pesa payouts in Kenya, you need their phone number and name.

async function createMpesaRecipient(name, phone) {
  const response = await axios.post(
    'https://api.paystack.co/transferrecipient',
    {
      type: 'mobile_money',
      name: name,
      account_number: phone, // M-Pesa phone number
      bank_code: 'MPESA',
      currency: 'KES',
    },
    { headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` } }
  );

  return response.data.data.recipient_code;
}

Timing. Do not split revenue in real time per transaction. Wait until Paystack settles funds to your balance, then run the distribution. This avoids trying to transfer money you have not received yet. Run distributions daily or after the event concludes, depending on your agreement with organizers.

Early Bird Pricing and Capacity Management

Early bird tickets are the most common pricing strategy for Kenyan events. The pattern is simple: offer a lower price for the first N tickets, then switch to the regular price. Your ticket_types table already supports this with quantity_available and sale_start/sale_end fields.

Automatic switchover. When early bird tickets sell out, your frontend should automatically show the next available ticket type at the regular price. Check availability on every page load and before every checkout initiation.

async function getAvailableTicketTypes(eventId) {
  const now = new Date().toISOString();

  const result = await db.query(
    `SELECT id, name, price, quantity_available,
            (quantity_available > 0
             AND (sale_start IS NULL OR sale_start <= $2)
             AND (sale_end IS NULL OR sale_end >= $2)) AS on_sale
     FROM ticket_types
     WHERE event_id = $1
     ORDER BY price ASC`,
    [eventId, now]
  );

  return result.rows;
}

Flash sale pressure. Popular events in Kenya sell out fast. Blankets and Wine, Koroga Festival, and big gospel concerts can move thousands of tickets in minutes. Your system needs to handle concurrent purchases without overselling. The atomic UPDATE with a WHERE guard (shown earlier) is essential. Do not use a read-then-write pattern where you check availability in one query and decrement in another. That gap is where overselling happens.

Waitlists. When tickets sell out, offer a waitlist. Collect the customer's phone number and notify them via SMS if tickets become available through cancellations or refunds. This is also valuable marketing data for the organizer: knowing that 500 people are on the waitlist helps justify larger venues for the next event.

The Complete Webhook Flow for Ticket Issuance

Your Paystack webhook handler is the backbone of the ticketing system. Here is the full flow from payment to ticket delivery.

const express = require('express');
const crypto = require('crypto');
const app = express();

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

  if (hash !== req.headers['x-paystack-signature']) {
    return res.sendStatus(400);
  }

  // 2. Return 200 immediately
  res.sendStatus(200);

  // 3. Process the event asynchronously
  const event = JSON.parse(req.body.toString());

  if (event.event === 'charge.success') {
    await handleChargeSuccess(event.data);
  }
});

async function handleChargeSuccess(data) {
  const reference = data.reference;

  // Idempotency check
  const existing = await db.query('SELECT status FROM orders WHERE paystack_reference = $1', [reference]);
  if (!existing.rows[0] || existing.rows[0].status === 'paid') {
    return; // Already processed or unknown order
  }

  // Verify with Paystack
  const verification = await axios.get(
    `https://api.paystack.co/transaction/verify/${reference}`,
    { headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` } }
  );

  if (verification.data.data.status !== 'success') {
    return;
  }

  const order = existing.rows[0];

  // Update order status
  await db.query('UPDATE orders SET status = $1 WHERE paystack_reference = $2', ['paid', reference]);

  // Update sold count
  await db.query(
    'UPDATE ticket_types SET quantity_sold = quantity_sold + $1 WHERE id = $2',
    [order.quantity, order.ticket_type_id]
  );

  // Generate tickets and QR codes
  const tickets = await createTicketsForOrder(order, order.event_id);

  // Build ticket page URL
  const ticketPageUrl = `https://yourapp.com/my-tickets/${order.id}`;
  const shortUrl = await shortenUrl(ticketPageUrl);

  // Send SMS with ticket
  await sendTicketSMS(order.customer_phone, order.event_name, shortUrl);
}

The flow is: verify signature, return 200 immediately (Paystack will retry if you do not respond quickly), check idempotency, verify the transaction server-side, update the order, generate tickets, and send SMS. Every step after the 200 response runs asynchronously. If your ticket generation or SMS sending fails, the customer still has a paid order that you can fulfil manually or through a retry queue.

Production Considerations

Database performance under load. Popular events create traffic spikes. Thousands of people checking out simultaneously. Use connection pooling (pg-pool for Node.js), index your ticket_types on event_id, and index orders on paystack_reference. Consider read replicas for the event listing pages while keeping writes on the primary database.

Fraud prevention. Common ticket fraud in Kenya includes: screenshots of someone else's QR code, buying tickets with stolen cards (chargebacks), and bulk buying for resale at inflated prices. Mitigate these by tying each ticket to a phone number and verifying it at check-in, setting per-customer purchase limits, and using Paystack's built-in fraud detection. For high-value events, consider requiring the attendee's name to match an ID at the door.

Accessibility. Not everyone buying tickets has a smartphone. The SMS delivery approach handles this, but also consider offering a USSD-based ticket lookup where attendees can dial a shortcode to get their ticket status. See our guide on USSD plus Paystack for feature phone users.

Organizer dashboard. Build a dashboard for event organizers showing: tickets sold by type, revenue collected, refunds processed, check-in rate, and a real-time view during the event. Organizers will use this to decide whether to increase marketing spend, adjust pricing, or plan for their next event. Export to CSV is a must since many organizers report to sponsors or boards.

For the full picture on accepting M-Pesa and card payments through Paystack in Kenya, start with our Paystack in Kenya hub guide.

Key Takeaways

  • M-Pesa should be the default payment option on your checkout. Over 80% of ticket purchases for Kenyan events will come through mobile money, not cards.
  • Generate unique QR codes for each ticket at the moment of confirmed payment, not at the moment of checkout initiation. This prevents ticket fraud from unpaid orders.
  • Use Paystack webhooks (charge.success) as your source of truth for ticket issuance. Never rely on the redirect callback alone to confirm payment.
  • Deliver tickets via SMS because not every ticket buyer has WhatsApp, a smartphone, or reliable email access. Include a short URL to a mobile-friendly ticket page.
  • Split payments for multi-organizer events using Paystack Transfer Recipients and batch transfers. Calculate each party share after deducting Paystack fees from the gross amount.
  • Implement real-time capacity tracking with database-level locks or atomic decrements to prevent overselling, especially during flash sales for popular events.
  • Build your refund flow around the Paystack Refunds API. Refund the ticket, update the QR code status to invalidated, and restore the capacity count in a single transaction.

Frequently Asked Questions

Can I use Paystack to split ticket revenue automatically between organizers?
Paystack does not have a built-in split payment feature for Kenya at the time of writing. You can achieve the same result by collecting all payments into your Paystack balance and then using the Transfers API to distribute each party share after settlement. Define split percentages per event and run distributions after the event or on a daily schedule.
How do I handle ticket buyers who do not have an email address?
Generate a placeholder email from their phone number, such as 0712345678@tickets.yourapp.com. Paystack requires an email for every transaction, but the phone number is your real customer identifier. Deliver tickets via SMS and build your customer lookup around phone numbers, not emails.
What happens if a ticket buyer loses their SMS or QR code?
Build a ticket recovery flow: the customer enters their phone number on your site, receives a new SMS with their ticket link, and can access their QR code again. Since the ticket is tied to a phone number in your database, recovery is straightforward. At the door, staff can also look up tickets by phone number as a fallback.
How do I prevent people from screenshotting and sharing QR codes?
At the database level, each QR code can only be used once. The first person to scan it gets in, and any subsequent scan shows "already checked in." For high-security events, tie the ticket to an ID number and verify it at the entrance. For general events, the one-scan rule is sufficient since the person who screenshots the code risks someone else using it before them.
How long does it take for M-Pesa ticket payments to confirm through Paystack?
M-Pesa payments through Paystack typically confirm within seconds. The customer receives the STK push on their phone, enters their PIN, and the charge.success webhook fires within a few seconds of that confirmation. The delay that customers feel is mostly the time spent entering the PIN and the M-Pesa processing step, not Paystack itself.

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