Building a Matatu or Transport Fare Collection System
For matatu fare collection where speed is critical, direct Daraja STK push is usually the better choice because it gives you faster confirmation and lower per-transaction costs at high volume. Paystack adds value when you also need card payments, a dashboard for the sacco owner, or plan to expand to other payment methods. This guide shows both approaches honestly and explains when each makes sense.
The Honest Assessment: Paystack vs Daraja for Transport
Before writing a single line of code, you need to understand the constraints of matatu fare collection. This is not an e-commerce checkout where the customer can wait 30 seconds for an STK push. A passenger standing at the door of a matatu on Mombasa Road during rush hour needs to pay and sit down in seconds. The conductor is not going to wait.
Why direct Daraja is usually the better fit for pure fare collection:
- Speed. Daraja STK push goes directly to Safaricom. Paystack adds a middle layer. In practice, the difference might be 1 to 3 seconds, but those seconds matter when 14 passengers are boarding.
- Cost at volume. A busy matatu route generates thousands of transactions daily. Check current per-transaction pricing for both Paystack and Daraja on their respective pricing pages. At high volume, the per-transaction fee difference compounds.
- M-Pesa is the only method that matters. Nobody is boarding a matatu with a Visa card. When you only need M-Pesa, the multi-method advantage of a gateway disappears.
- Your own paybill. With Daraja, the SACCO can have its own paybill number. Passengers see a familiar number on their phone. With Paystack, they see Paystack's paybill, which may cause confusion for passengers used to paying specific numbers.
When Paystack does make sense for transport:
- Corporate shuttle services where employees pay with company cards or the company pays monthly invoices.
- Inter-city bus companies that sell tickets online and need card, M-Pesa, and Pesalink options.
- Management tooling. Paystack's dashboard gives SACCO managers transaction reports without building a custom admin panel from scratch.
- Multi-country routes. If the transport company operates across Kenya, Uganda, and Tanzania, Paystack's multi-country support is useful.
This guide shows how to build the system with Paystack because that is the scope of this hub. But we will be clear about where Daraja would serve you better. If you decide Daraja is the right call, see the M-Pesa integration hub for direct Daraja guides.
Architecture Overview
A fare collection system has more moving parts than a typical payment integration because you have multiple actors (passengers, conductors, drivers, SACCO managers) and the system operates in a mobile, sometimes offline environment.
The components:
- Passenger-facing layer. A QR code displayed at the matatu entrance or on the windshield. The passenger scans it with their phone, which opens a payment page or triggers an STK push.
- Conductor app. A mobile app (or USSD menu for feature phones) that the conductor uses to confirm payments, manually trigger charges, and track collections.
- Backend API. Express server that manages routes, fares, vehicles, transactions, and reconciliation.
- Payment processor. Paystack for card + M-Pesa, or Daraja for M-Pesa only.
- Management dashboard. Web portal for SACCO managers to view daily collections, vehicle performance, and financial reports.
The QR code contains a URL like https://yourapp.com/pay/KBZ-123A/route-58 where KBZ-123A is the vehicle registration and route-58 is the route. When scanned, the passenger sees the fare amount and enters their phone number to pay.
Data Model
The data model for transport fare collection centers around vehicles, routes, trips, and fares. Here is a practical starting schema:
CREATE TABLE vehicles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
registration VARCHAR(20) UNIQUE NOT NULL, -- e.g., 'KBZ 123A'
sacco_id UUID NOT NULL,
driver_phone VARCHAR(20),
conductor_phone VARCHAR(20),
qr_code_url VARCHAR(500),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE routes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL, -- e.g., 'CBD - Rongai'
code VARCHAR(20) UNIQUE NOT NULL, -- e.g., 'route-58'
stages JSONB DEFAULT '[]', -- [{name: "Kencom", order: 1}, {name: "Langata Rd", order: 2}, ...]
base_fare_cents INTEGER NOT NULL, -- minimum fare in cents
per_stage_cents INTEGER DEFAULT 0, -- additional fare per stage
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE trips (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id UUID REFERENCES vehicles(id),
route_id UUID REFERENCES routes(id),
started_at TIMESTAMPTZ DEFAULT NOW(),
ended_at TIMESTAMPTZ,
passenger_count INTEGER DEFAULT 0,
total_collected_cents INTEGER DEFAULT 0,
status VARCHAR(20) DEFAULT 'active' -- active, completed
);
CREATE TABLE fare_payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
trip_id UUID REFERENCES trips(id),
vehicle_id UUID REFERENCES vehicles(id),
route_id UUID REFERENCES routes(id),
passenger_phone VARCHAR(20),
amount_cents INTEGER NOT NULL,
boarding_stage VARCHAR(100),
alighting_stage VARCHAR(100),
payment_method VARCHAR(20), -- 'mpesa', 'card', 'cash'
paystack_reference VARCHAR(255),
status VARCHAR(20) DEFAULT 'pending', -- pending, paid, failed
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE daily_reconciliation (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
vehicle_id UUID REFERENCES vehicles(id),
date DATE NOT NULL,
total_trips INTEGER DEFAULT 0,
total_passengers INTEGER DEFAULT 0,
total_collected_cents INTEGER DEFAULT 0,
total_cash_cents INTEGER DEFAULT 0,
total_digital_cents INTEGER DEFAULT 0,
discrepancy_cents INTEGER DEFAULT 0,
reconciled BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(vehicle_id, date)
);
Note the stages system. Matatu fares in Kenya are not always flat. The fare from CBD to Langata is different from CBD to Rongai. Some SACCOs charge by stage. The stages JSON array and per_stage_cents field let you calculate variable fares.
QR Code Payment Flow
The passenger scans a QR code at the matatu entrance. The QR code encodes a URL that identifies the vehicle and route. Here is how the flow works end to end:
// Generate QR code URL for a vehicle
function getPaymentUrl(vehicleRegistration, routeCode) {
return `${process.env.APP_URL}/fare/${vehicleRegistration}/${routeCode}`;
}
// GET /fare/:vehicleReg/:routeCode - Passenger landing page
app.get('/fare/:vehicleReg/:routeCode', async (req, res) => {
const vehicle = await db.query(
'SELECT * FROM vehicles WHERE registration = $1 AND is_active = TRUE',
[req.params.vehicleReg]
);
const route = await db.query(
'SELECT * FROM routes WHERE code = $1 AND is_active = TRUE',
[req.params.routeCode]
);
if (!vehicle.rows[0] || !route.rows[0]) {
return res.status(404).send('Vehicle or route not found');
}
// Return the fare information and a payment form
res.json({
vehicle: vehicle.rows[0].registration,
route: route.rows[0].name,
fare: route.rows[0].base_fare_cents,
stages: route.rows[0].stages,
});
});
// POST /fare/pay - Process the fare payment
app.post('/fare/pay', async (req, res) => {
const { vehicleReg, routeCode, phone, boardingStage, alightingStage } = req.body;
const route = await db.query('SELECT * FROM routes WHERE code = $1', [routeCode]);
const vehicle = await db.query('SELECT * FROM vehicles WHERE registration = $1', [vehicleReg]);
// Calculate fare based on stages
const fare = calculateFare(route.rows[0], boardingStage, alightingStage);
// Find or create the active trip
let trip = await db.query(
"SELECT * FROM trips WHERE vehicle_id = $1 AND status = 'active' ORDER BY started_at DESC LIMIT 1",
[vehicle.rows[0].id]
);
if (!trip.rows[0]) {
trip = await db.query(
'INSERT INTO trips (vehicle_id, route_id) VALUES ($1, $2) RETURNING *',
[vehicle.rows[0].id, route.rows[0].id]
);
}
// Create fare payment record
const payment = await db.query(
`INSERT INTO fare_payments
(trip_id, vehicle_id, route_id, passenger_phone, amount_cents, boarding_stage, alighting_stage, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending')
RETURNING id`,
[trip.rows[0].id, vehicle.rows[0].id, route.rows[0].id, phone, fare, boardingStage, alightingStage]
);
// Initiate M-Pesa charge through Paystack
const chargeRes = 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: `${phone}@fare.local`, // Paystack requires email; use a placeholder
amount: fare,
currency: 'KES',
mobile_money: { phone, provider: 'mpesa' },
metadata: {
fare_payment_id: payment.rows[0].id,
vehicle: vehicleReg,
route: routeCode,
},
}),
});
const chargeData = await chargeRes.json();
await db.query(
'UPDATE fare_payments SET paystack_reference = $1 WHERE id = $2',
[chargeData.data.reference, payment.rows[0].id]
);
res.json({
status: chargeData.data.status,
message: 'Check your phone for the M-Pesa prompt',
reference: chargeData.data.reference,
});
});
The email field is a Paystack requirement. Since matatu passengers do not have email addresses associated with their fare payments, use a placeholder format like phone@fare.local. This is a common pattern for walk-in customers.
The fare calculation function handles variable pricing:
function calculateFare(route, boardingStage, alightingStage) {
if (!boardingStage || !alightingStage) {
return route.base_fare_cents;
}
const stages = route.stages;
const boardingIndex = stages.findIndex(s => s.name === boardingStage);
const alightingIndex = stages.findIndex(s => s.name === alightingStage);
if (boardingIndex === -1 || alightingIndex === -1) {
return route.base_fare_cents;
}
const stageCount = Math.abs(alightingIndex - boardingIndex);
return route.base_fare_cents + (stageCount * route.per_stage_cents);
}
The Conductor App
The conductor needs a simple interface to manage fares during a trip. This is a mobile-first web app (or a lightweight native app) that shows:
- The current trip status and passenger count.
- A list of payments for the current trip (paid, pending, failed).
- A manual charge trigger for passengers who cannot scan the QR code.
- End-of-trip summary.
// GET /api/conductor/trip/:vehicleId - Get current trip status
app.get('/api/conductor/trip/:vehicleId', async (req, res) => {
const trip = await db.query(
`SELECT t.*, r.name as route_name
FROM trips t
JOIN routes r ON t.route_id = r.id
WHERE t.vehicle_id = $1 AND t.status = 'active'
ORDER BY t.started_at DESC LIMIT 1`,
[req.params.vehicleId]
);
if (!trip.rows[0]) {
return res.json({ active_trip: null });
}
const payments = await db.query(
`SELECT id, passenger_phone, amount_cents, boarding_stage, status, paid_at
FROM fare_payments
WHERE trip_id = $1
ORDER BY created_at DESC`,
[trip.rows[0].id]
);
const paidCount = payments.rows.filter(p => p.status === 'paid').length;
const paidTotal = payments.rows
.filter(p => p.status === 'paid')
.reduce((sum, p) => sum + p.amount_cents, 0);
res.json({
active_trip: {
id: trip.rows[0].id,
route: trip.rows[0].route_name,
started_at: trip.rows[0].started_at,
passengers_paid: paidCount,
total_collected: paidTotal,
payments: payments.rows,
},
});
});
// POST /api/conductor/trip/end - End the current trip
app.post('/api/conductor/trip/end', async (req, res) => {
const { vehicleId } = req.body;
const trip = await db.query(
"SELECT * FROM trips WHERE vehicle_id = $1 AND status = 'active' ORDER BY started_at DESC LIMIT 1",
[vehicleId]
);
if (!trip.rows[0]) {
return res.status(404).json({ error: 'No active trip' });
}
const payments = await db.query(
"SELECT SUM(amount_cents) as total, COUNT(*) as count FROM fare_payments WHERE trip_id = $1 AND status = 'paid'",
[trip.rows[0].id]
);
await db.query(
"UPDATE trips SET status = 'completed', ended_at = NOW(), passenger_count = $1, total_collected_cents = $2 WHERE id = $3",
[payments.rows[0].count, payments.rows[0].total || 0, trip.rows[0].id]
);
res.json({
trip_ended: true,
summary: {
passengers: parseInt(payments.rows[0].count),
total_collected: parseInt(payments.rows[0].total) || 0,
},
});
});
The conductor app must work offline. Cache the current trip state locally on the phone. When the matatu enters an area with no data coverage (common on routes outside Nairobi), the app should still display the passenger list and allow the conductor to mark cash payments. Sync to the server when connectivity returns.
Daily Reconciliation
SACCO owners need to know exactly how much each vehicle collected each day. This is not a nice-to-have feature. It is the entire reason many SACCOs would adopt a digital fare system. The current cash-based system leaks money because conductors underreport collections.
// Run daily at midnight or end of business
async function runDailyReconciliation() {
const today = new Date().toISOString().split('T')[0];
// Get all vehicles with trips today
const vehicles = await db.query(
`SELECT DISTINCT fp.vehicle_id, v.registration
FROM fare_payments fp
JOIN vehicles v ON fp.vehicle_id = v.id
WHERE DATE(fp.created_at) = $1`,
[today]
);
for (const vehicle of vehicles.rows) {
const stats = await db.query(
`SELECT
COUNT(DISTINCT trip_id) as total_trips,
COUNT(*) as total_passengers,
SUM(CASE WHEN status = 'paid' THEN amount_cents ELSE 0 END) as total_collected,
SUM(CASE WHEN payment_method = 'cash' THEN amount_cents ELSE 0 END) as total_cash,
SUM(CASE WHEN payment_method IN ('mpesa', 'card') THEN amount_cents ELSE 0 END) as total_digital
FROM fare_payments
WHERE vehicle_id = $1 AND DATE(created_at) = $2`,
[vehicle.vehicle_id, today]
);
await db.query(
`INSERT INTO daily_reconciliation
(vehicle_id, date, total_trips, total_passengers, total_collected_cents, total_cash_cents, total_digital_cents)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (vehicle_id, date) DO UPDATE SET
total_trips = EXCLUDED.total_trips,
total_passengers = EXCLUDED.total_passengers,
total_collected_cents = EXCLUDED.total_collected_cents,
total_cash_cents = EXCLUDED.total_cash_cents,
total_digital_cents = EXCLUDED.total_digital_cents`,
[
vehicle.vehicle_id, today,
stats.rows[0].total_trips,
stats.rows[0].total_passengers,
stats.rows[0].total_collected || 0,
stats.rows[0].total_cash || 0,
stats.rows[0].total_digital || 0,
]
);
}
}
The reconciliation report for SACCO managers should show:
- Per-vehicle daily collection totals (digital vs cash).
- Number of trips and passengers per vehicle.
- Average fare per passenger (useful for spotting conductors who undercharge or overcharge).
- Comparison against expected collections based on passenger count and standard fares.
- Flagged discrepancies where cash collections seem low relative to passenger counts.
Cross-reference digital payments (which are verifiable through Paystack or M-Pesa statements) against reported cash collections. If a vehicle carried 200 passengers digitally but only reports 50 cash passengers, something is off. The data tells the story.
Volume and Cost Considerations
Transport fare collection generates high transaction volumes at low individual amounts. This is the opposite of what most payment gateways are optimized for.
Consider a mid-size matatu SACCO with 50 vehicles, each doing 10 trips a day with an average of 14 passengers per trip. That is 7,000 transactions per day. At 30 days a month, that is 210,000 transactions.
At that volume, per-transaction fees matter significantly. Check current rates on paystack.com/pricing and compare against Daraja's fee structure. The percentage-based component is usually the same, but flat fees per transaction can add up when your average fare is low.
Some practical strategies to manage costs:
- Negotiate volume pricing. At 200K+ transactions per month, contact Paystack about custom pricing. Payment processors offer volume discounts for high-transaction-count merchants.
- Batch settlements. Instead of settling per transaction, batch settlements reduce processing overhead.
- Hybrid approach. Use Paystack for the dashboard, reporting, and card payments. Use direct Daraja for the high-volume M-Pesa fare collection. Merge the data in your reconciliation layer. See Running Paystack and Daraja Side by Side.
The hybrid approach is what most serious transport tech companies in Nairobi end up using. Paystack for the business layer, Daraja for the transaction layer. Your reconciliation system merges both streams.
Deployment Notes for Transport
The QR codes must be durable. A paper QR code on a matatu windshield will last about two days before it is unreadable from sun damage, rain, or wear. Use metal or plastic-laminated QR code plates. Budget for physical signage as part of the deployment cost.
The conductor app must be lightweight. Conductors use low-end Android phones on data-limited plans. Build a Progressive Web App (PWA) that loads fast, caches aggressively, and uses minimal data. A 20MB React bundle is unacceptable. Aim for under 500KB initial load.
SMS fallback for feature phones. Not every passenger has a smartphone to scan QR codes. Support a USSD or SMS-based payment flow where the passenger sends a code to a short number. This is harder to build but essential for routes serving passengers with feature phones.
Webhook reliability on the road. Your server must be highly available because payment confirmations drive the boarding process. Use a cloud provider with good uptime SLAs. Set up health monitoring and alerts. If your webhook endpoint goes down during morning rush hour, you have a SACCO full of angry conductors.
Test on actual matatu routes. Lab testing is not enough. Ride the route your system will serve. Test the QR scan at the matatu stage. Test it inside the moving vehicle. Test it when the matatu is full and the passenger is standing. Test it in Kibera where data coverage is inconsistent. The real environment will reveal problems you never imagined.
For the full Kenya payment integration context, see Paystack in Kenya: M-Pesa, Pesalink, and the Daraja Question. For direct M-Pesa integration when Paystack is not the right fit, see the M-Pesa hub.
If you want to build transport tech or any other Kenyan product with proper payment integration, the McTaba 26-week bootcamp (KES 120,000) teaches you to ship real products that handle real money. The M-Pesa micro-course covers Daraja integration specifically.
Key Takeaways
- ✓Transport fare collection has a speed requirement that most payment use cases do not. The passenger needs to pay and board in under 30 seconds. This constraint shapes every technical decision.
- ✓Direct Daraja STK push is generally faster and cheaper per transaction for pure M-Pesa fare collection. Paystack adds a processing layer that introduces latency and an additional fee.
- ✓Paystack makes sense for transport when you need card payments (corporate commuters with company cards), a management dashboard, or multi-method acceptance beyond M-Pesa alone.
- ✓QR codes at the matatu entrance work well for boarding. The passenger scans, the system triggers an STK push, and the conductor confirms payment on their app before the matatu departs.
- ✓Daily reconciliation is non-negotiable. SACCO owners need to know exactly how much each vehicle collected each day. Build automated end-of-day reports.
- ✓Offline handling is critical. Matatus operate on routes where mobile data drops out. Your conductor app must work offline and sync when connectivity returns.
- ✓Volume matters for cost. A matatu doing 200 trips a day with 14 passengers each generates 2,800 transactions. At scale, per-transaction fee differences between Paystack and Daraja add up fast.
Frequently Asked Questions
- Is Paystack or Daraja better for matatu fare collection?
- For pure M-Pesa fare collection, direct Daraja is usually the better fit because it is faster and has lower per-transaction costs at high volume. Paystack adds value when you need card payments, a ready-made dashboard, or multi-payment-method support. Many transport tech companies use both: Daraja for the M-Pesa transactions and Paystack for the business tooling.
- How do I handle passengers without smartphones?
- Support a USSD or SMS-based payment flow alongside the QR code. The passenger dials a USSD code or sends an SMS with the vehicle number, and the system triggers an STK push to their phone. This works on feature phones that support M-Pesa. You also need to continue accepting cash. Going fully cashless in Kenyan public transport is not realistic today.
- What happens when the matatu loses data coverage mid-route?
- The conductor app must work offline. Cache the current trip state, passenger list, and pending payments locally. When connectivity returns, sync everything to the server. For passengers trying to pay during the outage, fall back to cash and record it in the app. The daily reconciliation will account for the offline period.
- How do I prevent conductors from pocketing cash and underreporting?
- This is the core value proposition of a digital fare system. Digital payments create an auditable trail that cash does not. The reconciliation report compares digital passenger counts against reported cash passengers. If digital records show 180 passengers but the conductor reports 40 cash passengers when the vehicle seats 33, the numbers do not add up. Over time, increasing the share of digital payments reduces the opportunity for underreporting.
- Can I generate QR codes dynamically for different routes?
- Yes, but static QR codes are better for matatus. A static QR code points to a URL that identifies the vehicle. The route can be selected on the payment page or detected automatically based on the current trip the conductor has started. If you generate new QR codes for each trip, you need to reprint or update them, which is impractical for a physical matatu.
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