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.
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:
When Paystack does make sense for transport:
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.
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:
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.
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
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.
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 needs a simple interface to manage fares during a trip. This is a mobile-first web app (or a lightweight native app) that shows:
// 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.
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:
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.
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:
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.
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.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course