Building an Agrovet or Farm Input Payment System
Build a farm input payment system by creating a product catalog of seeds, fertilizers, and tools, registering farmers by phone number, and collecting payments via Paystack M-Pesa STK push. Design for low smartphone penetration by sending SMS order confirmations instead of relying on a web portal. Use Paystack Transfers to pay suppliers, and support group purchasing where multiple farmers pool funds for bulk orders.
Why Agrovets Need a Digital Payment System
An agrovet in Kakamega or Meru handles hundreds of transactions a week during planting season. Farmers come in, buy seeds and fertilizer, and pay cash or M-Pesa. The M-Pesa payments show up as a stream of confirmations on the shop owner's phone. "KES 2,500 from 0722XXXXXX." No product details. No customer name. No receipt. No way to track who bought what, or who still owes money from last season's credit arrangement.
Digital payment systems for agrovets solve three problems:
- Tracking. Every sale is recorded with the farmer's name, what they bought, and how they paid. The agrovet knows their inventory, their revenue, and their outstanding credit at any moment.
- Credit management. Many agrovets extend credit to farmers who pay after harvest. Without a system, credit tracking is a notebook exercise that falls apart after two seasons. A digital system tracks credit limits, outstanding balances, and payment history.
- Group orders. Farmer cooperatives and self-help groups order inputs in bulk. Coordinating payments from 30 farmers for a single bulk fertilizer order by phone is miserable. A system where each farmer pays their share and the order is placed when the total is met is vastly better.
The user interface for this system is not a fancy web application. Many of the end users are farmers on feature phones with limited data. The primary interface is M-Pesa for payments and SMS for everything else. A web dashboard exists for the agrovet owner and their staff, not for the farmers.
Product Catalog: Seeds, Fertilizer, and Tools
The product catalog is straightforward but needs a few agriculture-specific fields.
const createProductsTable = `
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
sku VARCHAR(30) UNIQUE NOT NULL,
name VARCHAR(200) NOT NULL,
category VARCHAR(50) NOT NULL, -- 'seeds', 'fertilizer', 'pesticide', 'tools', 'feed'
brand VARCHAR(100),
unit VARCHAR(30) NOT NULL, -- 'kg', '50kg bag', 'litre', 'packet', 'piece'
unit_price INT NOT NULL, -- In cents (KES)
bulk_price INT, -- Discounted price for bulk/group orders
bulk_minimum INT, -- Minimum quantity for bulk pricing
stock_quantity INT DEFAULT 0,
season VARCHAR(20), -- 'long_rains', 'short_rains', 'all_year'
crop_type VARCHAR(100), -- 'maize', 'beans', 'wheat', 'general'
supplier_id UUID REFERENCES suppliers(id),
active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW()
);
`;
const createSuppliersTable = `
CREATE TABLE suppliers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
contact_phone VARCHAR(15) NOT NULL,
bank_code VARCHAR(20),
account_number VARCHAR(30),
paystack_recipient_code VARCHAR(50), -- For automated payouts
created_at TIMESTAMP DEFAULT NOW()
);
`;
Key design decisions:
- Bulk pricing. When a farmer group buys 100 bags of fertilizer, they pay less per bag than a farmer buying 2. The bulk_price and bulk_minimum fields handle this.
- Season and crop type. These fields power the ordering experience. Before long rains, the system can surface maize seeds and DAP fertilizer. Before short rains, different varieties. This matters because farmers in different regions plant different crops at different times.
- Supplier linkage. Each product comes from a supplier. When orders are placed, the system can calculate how much to pay each supplier and process the payout automatically.
- Unit flexibility. Fertilizer is sold by the 50kg bag. Seeds come in 1kg or 2kg packets. Pesticide is sold by the litre. The unit field keeps pricing clear.
Farmer Registration
Farmers register by phone number. That is the primary identifier. Many farmers will not have email addresses, and asking for one creates friction. The phone number is the login, the payment method (M-Pesa), and the communication channel (SMS).
const createFarmersTable = `
CREATE TABLE farmers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
phone VARCHAR(15) UNIQUE NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
location VARCHAR(200), -- Village, ward, or nearest town
county VARCHAR(50),
farm_size VARCHAR(50), -- '0.5 acres', '2 acres', etc.
primary_crops TEXT, -- 'maize, beans'
group_id UUID REFERENCES farmer_groups(id),
credit_limit INT DEFAULT 0, -- In cents, approved by agrovet
credit_used INT DEFAULT 0,
id_number VARCHAR(20),
created_at TIMESTAMP DEFAULT NOW()
);
`;
const createFarmerGroupsTable = `
CREATE TABLE farmer_groups (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
registration_number VARCHAR(50), -- County registration number
county VARCHAR(50),
ward VARCHAR(100),
chair_phone VARCHAR(15) NOT NULL,
secretary_phone VARCHAR(15),
member_count INT DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
`;
// Registration endpoint
app.post('/api/farmers/register', async (req, res) => {
const { phone, firstName, lastName, location, county, groupId } = req.body;
// Normalize phone number to 07XXXXXXXX or 01XXXXXXXX format
const normalizedPhone = normalizeKenyanPhone(phone);
const farmer = await db.insert('farmers', {
phone: normalizedPhone,
first_name: firstName,
last_name: lastName,
location,
county,
group_id: groupId || null,
credit_limit: 0,
});
// Send welcome SMS
await sendSMS(
normalizedPhone,
`Welcome to ${process.env.AGROVET_NAME}. You are now registered. Order farm inputs by visiting our shop or calling ${process.env.AGROVET_PHONE}. Your account: ${farmer.id.slice(0, 8)}.`
);
res.json({ farmerId: farmer.id });
});
Registration can happen at the agrovet counter (the shop attendant enters the farmer's details) or via a USSD flow for farmers who do not visit the shop physically. The USSD approach is covered in USSD Plus Paystack: Reaching Feature Phone Users.
Order and Payment Flow: M-Pesa First
The order flow is designed for the reality of rural Kenya. A farmer calls the agrovet, or walks in, or sends a message through the group chair. The agrovet creates the order in the system. The farmer pays via M-Pesa. The agrovet arranges delivery or the farmer picks up.
// Create an order and initiate M-Pesa payment
app.post('/api/orders', async (req, res) => {
const { farmerId, items } = req.body;
// items: [{ productId, quantity }]
const farmer = await db.query('SELECT * FROM farmers WHERE id = $1', [farmerId]);
// Calculate order total
let totalAmount = 0;
const orderItems = [];
for (const item of items) {
const product = await db.query('SELECT * FROM products WHERE id = $1', [item.productId]);
// Check stock
if (product[0].stock_quantity < item.quantity) {
return res.status(400).json({
error: `Insufficient stock for ${product[0].name}. Available: ${product[0].stock_quantity}`,
});
}
// Apply bulk pricing if applicable
const unitPrice = (item.quantity >= (product[0].bulk_minimum || Infinity))
? (product[0].bulk_price || product[0].unit_price)
: product[0].unit_price;
const itemTotal = unitPrice * item.quantity;
totalAmount += itemTotal;
orderItems.push({
product_id: product[0].id,
product_name: product[0].name,
quantity: item.quantity,
unit_price: unitPrice,
total_price: itemTotal,
});
}
const orderNumber = `ORD-${Date.now()}`;
// Create the order
const order = await db.insert('orders', {
order_number: orderNumber,
farmer_id: farmerId,
total_amount: totalAmount,
status: 'pending_payment',
});
// Insert order items
for (const item of orderItems) {
await db.insert('order_items', { order_id: order.id, ...item });
}
// Initiate M-Pesa payment
const reference = `AGR-${orderNumber}`;
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: `${farmer[0].phone}@farmer.portal`,
amount: totalAmount,
currency: 'KES',
reference,
mobile_money: {
phone: farmer[0].phone,
provider: 'mpesa',
},
metadata: {
farmer_id: farmer[0].id,
farmer_phone: farmer[0].phone,
farmer_name: `${farmer[0].first_name} ${farmer[0].last_name}`,
order_id: order.id,
order_number: orderNumber,
item_count: orderItems.length,
custom_fields: [
{ display_name: 'Farmer', variable_name: 'farmer', value: `${farmer[0].first_name} ${farmer[0].last_name}` },
{ display_name: 'Order', variable_name: 'order', value: orderNumber },
{ display_name: 'Items', variable_name: 'items', value: orderItems.map(i => i.product_name).join(', ') },
],
},
}),
});
const data = await response.json();
// Send SMS to farmer about the pending payment
const totalKES = (totalAmount / 100).toLocaleString();
await sendSMS(
farmer[0].phone,
`Order ${orderNumber}: ${orderItems.map(i => `${i.product_name} x${i.quantity}`).join(', ')}. Total: KES ${totalKES}. Check your phone for the M-Pesa prompt.`
);
res.json({ orderNumber, totalAmount, reference, status: 'payment_pending' });
});
The SMS is not a nice touch. It is the primary user interface. The farmer sees the SMS, checks their phone for the STK push, enters their PIN, and the order is confirmed. They do not need to open a browser, download an app, or create an account on a website.
Group Purchasing: Pooled Orders
Farmer cooperatives and self-help groups buy inputs together. Thirty farmers pool their money to buy a lorry-load of fertilizer at wholesale prices. Your system needs to handle this pattern.
The group order flow:
- The group chair or secretary creates a group order specifying the product and total quantity.
- Each member is assigned their share (or chooses how many bags they want).
- Each member pays their share individually via M-Pesa.
- When the total is reached, the order is placed with the supplier.
- Delivery goes to a central collection point.
// Create a group order
app.post('/api/group-orders', async (req, res) => {
const { groupId, productId, totalQuantity, memberAllocations } = req.body;
// memberAllocations: [{ farmerId, quantity }]
const product = await db.query('SELECT * FROM products WHERE id = $1', [productId]);
const bulkPrice = product[0].bulk_price || product[0].unit_price;
const totalAmount = bulkPrice * totalQuantity;
const groupOrder = await db.insert('group_orders', {
group_id: groupId,
product_id: productId,
total_quantity: totalQuantity,
unit_price: bulkPrice,
total_amount: totalAmount,
collected_amount: 0,
status: 'collecting', // 'collecting', 'funded', 'ordered', 'delivered'
});
// Create individual allocations
for (const allocation of memberAllocations) {
const memberAmount = bulkPrice * allocation.quantity;
await db.insert('group_order_members', {
group_order_id: groupOrder.id,
farmer_id: allocation.farmerId,
quantity: allocation.quantity,
amount_due: memberAmount,
amount_paid: 0,
status: 'unpaid',
});
// Send SMS to each member
const farmer = await db.query('SELECT * FROM farmers WHERE id = $1', [allocation.farmerId]);
const amountKES = (memberAmount / 100).toLocaleString();
await sendSMS(
farmer[0].phone,
`Group order: ${product[0].name} x${allocation.quantity} bags. Your share: KES ${amountKES}. Pay via M-Pesa to confirm your allocation. Call ${process.env.AGROVET_PHONE} to pay.`
);
}
res.json({ groupOrderId: groupOrder.id, totalAmount, memberCount: memberAllocations.length });
});
// Process a member's payment toward a group order
app.post('/api/group-orders/:groupOrderId/pay', async (req, res) => {
const { groupOrderId } = req.params;
const { farmerId } = req.body;
const member = await db.query(
'SELECT * FROM group_order_members WHERE group_order_id = $1 AND farmer_id = $2',
[groupOrderId, farmerId]
);
if (member[0].status === 'paid') {
return res.status(400).json({ error: 'Already paid' });
}
const farmer = await db.query('SELECT * FROM farmers WHERE id = $1', [farmerId]);
const reference = `GRP-${groupOrderId.slice(0, 8)}-${farmerId.slice(0, 8)}`;
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: `${farmer[0].phone}@farmer.portal`,
amount: member[0].amount_due,
currency: 'KES',
reference,
mobile_money: {
phone: farmer[0].phone,
provider: 'mpesa',
},
metadata: {
farmer_id: farmerId,
group_order_id: groupOrderId,
payment_type: 'group_contribution',
custom_fields: [
{ display_name: 'Farmer', variable_name: 'farmer', value: `${farmer[0].first_name} ${farmer[0].last_name}` },
{ display_name: 'Type', variable_name: 'type', value: 'Group Order Contribution' },
],
},
}),
});
res.json({ status: 'pending', reference });
});
The webhook handler for group order payments needs to check whether the group order is now fully funded. When it is, the system should notify the agrovet owner and the group chair so the supplier order can be placed.
// In webhook handler, after confirming a group order payment:
async function checkGroupOrderFunding(groupOrderId) {
const groupOrder = await db.query('SELECT * FROM group_orders WHERE id = $1', [groupOrderId]);
const members = await db.query(
'SELECT * FROM group_order_members WHERE group_order_id = $1',
[groupOrderId]
);
const totalCollected = members.reduce((sum, m) => sum + m.amount_paid, 0);
if (totalCollected >= groupOrder[0].total_amount) {
await db.query(
'UPDATE group_orders SET status = $1, collected_amount = $2 WHERE id = $3',
['funded', totalCollected, groupOrderId]
);
// Notify agrovet owner
await sendSMS(
process.env.AGROVET_PHONE,
`Group order ${groupOrderId.slice(0, 8)} is fully funded. Total: KES ${(totalCollected / 100).toLocaleString()}. Place the supplier order.`
);
}
}
Credit and Loan Integration for Farm Inputs
Farmers often need inputs before they have the cash to pay. This is the nature of agriculture: you invest before planting, and the returns come months later at harvest. An agrovet payment system that does not handle credit is ignoring the reality of its customers.
There are two approaches to credit:
Approach 1: Agrovet-managed credit. The agrovet owner sets a credit limit for each farmer based on their payment history and relationship. The farmer orders inputs on credit and pays after harvest.
// Create a credit order
app.post('/api/orders/credit', async (req, res) => {
const { farmerId, items, dueDate } = req.body;
const farmer = await db.query('SELECT * FROM farmers WHERE id = $1', [farmerId]);
// Calculate order total
let totalAmount = 0;
for (const item of items) {
const product = await db.query('SELECT * FROM products WHERE id = $1', [item.productId]);
totalAmount += product[0].unit_price * item.quantity;
}
// Check credit availability
const availableCredit = farmer[0].credit_limit - farmer[0].credit_used;
if (totalAmount > availableCredit) {
return res.status(400).json({
error: `Insufficient credit. Available: KES ${(availableCredit / 100).toLocaleString()}`,
});
}
// Create the order with credit payment
const orderNumber = `CRD-${Date.now()}`;
const order = await db.insert('orders', {
order_number: orderNumber,
farmer_id: farmerId,
total_amount: totalAmount,
payment_type: 'credit',
due_date: dueDate, // e.g., 90 days from now (after harvest)
status: 'confirmed', // No payment needed now
});
// Update farmer credit usage
await db.query(
'UPDATE farmers SET credit_used = credit_used + $1 WHERE id = $2',
[totalAmount, farmerId]
);
// SMS confirmation
const totalKES = (totalAmount / 100).toLocaleString();
const dueDateStr = new Date(dueDate).toLocaleDateString('en-KE');
await sendSMS(
farmer[0].phone,
`Credit order ${orderNumber} confirmed. Amount: KES ${totalKES}. Due date: ${dueDateStr}. Pick up your inputs at the shop.`
);
res.json({ orderNumber, totalAmount, dueDate, paymentType: 'credit' });
});
Approach 2: Third-party agricultural lending. Platforms like DigiFarm, Apollo Agriculture, or Musoni provide credit to farmers. Your system integrates with the lending platform's API. The farmer applies for credit, the lender approves it, the agrovet fulfills the order, and the lender pays the agrovet via Paystack Transfer. The farmer repays the lender later.
For the repayment of agrovet-managed credit, use Paystack to collect when the farmer is ready to pay. Send an SMS reminder two weeks before the due date, then again at the due date. Make the payment process as simple as replying to the SMS (via a payment link) or calling the agrovet to trigger an STK push.
SMS Confirmations and Delivery Tracking
Every state change in an order should trigger an SMS. This is not optional in a system where the end users do not have a web portal to check. SMS is the status dashboard for farmers.
// SMS messages for each order state
const orderSMSTemplates = {
payment_confirmed: (order, farmer) =>
`Payment of KES ${(order.total_amount / 100).toLocaleString()} received for order ${order.order_number}. Your inputs will be ready for pickup/delivery.`,
ready_for_pickup: (order, farmer) =>
`Order ${order.order_number} is ready for pickup at ${process.env.AGROVET_NAME}, ${process.env.AGROVET_LOCATION}. Bring this SMS as reference.`,
out_for_delivery: (order, farmer, driver) =>
`Order ${order.order_number} is on the way. Driver: ${driver.name}, Phone: ${driver.phone}. Expected arrival: today by ${order.estimated_delivery_time}.`,
delivered: (order, farmer) =>
`Order ${order.order_number} has been delivered. If you have any issues, call ${process.env.AGROVET_PHONE}. Thank you for your business.`,
credit_reminder: (order, farmer) =>
`Reminder: Your credit balance of KES ${(order.balance / 100).toLocaleString()} for order ${order.order_number} is due on ${new Date(order.due_date).toLocaleDateString('en-KE')}. Pay via M-Pesa to ${process.env.AGROVET_PHONE}.`,
};
For delivery tracking, keep it simple. Farmers do not need GPS-based real-time tracking. They need three things:
- Confirmation that the order is on the way
- The driver's phone number in case they need to coordinate the drop-off location
- Confirmation that it was delivered
The driver can update the delivery status through a simple interface (even a phone call to the agrovet attendant who updates the system). When the status changes to "delivered," the SMS goes out automatically.
Supplier Payouts via Paystack Transfers
When the agrovet sources products from suppliers or distributors, they need to pay those suppliers. If the agrovet collects payments through Paystack, they can use Paystack Transfers to pay suppliers directly from their Paystack balance.
// Pay a supplier for fulfilled orders
async function paySupplier(supplierId, amount, reason) {
const supplier = await db.query('SELECT * FROM suppliers WHERE id = $1', [supplierId]);
// Create transfer recipient if not already created
let recipientCode = supplier[0].paystack_recipient_code;
if (!recipientCode) {
const recipientRes = await fetch('https://api.paystack.co/transferrecipient', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
type: supplier[0].bank_code === 'MPESA' ? 'mobile_money' : 'nuban',
name: supplier[0].name,
account_number: supplier[0].account_number,
bank_code: supplier[0].bank_code,
currency: 'KES',
}),
});
const recipient = await recipientRes.json();
recipientCode = recipient.data.recipient_code;
// Save for future use
await db.query(
'UPDATE suppliers SET paystack_recipient_code = $1 WHERE id = $2',
[recipientCode, supplierId]
);
}
// Initiate the transfer
const transferRes = await fetch('https://api.paystack.co/transfer', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
source: 'balance',
amount,
recipient: recipientCode,
reason,
}),
});
const transfer = await transferRes.json();
return {
transferCode: transfer.data.transfer_code,
status: transfer.data.status,
};
}
This closes the money loop: farmer pays agrovet (via Paystack M-Pesa), agrovet pays supplier (via Paystack Transfer). The agrovet's Paystack dashboard shows both sides of the equation. For more on the Transfer API, see Paystack Transfers to Paybill and Till Numbers.
Handling Seasonal Ordering Patterns
Agriculture is seasonal. In Kenya, the long rains (March to May) and short rains (October to December) drive two major ordering cycles. Two to four weeks before each planting season, the agrovet will process more orders than in the entire rest of the quarter.
What this means for your system:
- Inventory management. The agrovet needs to stock up before the rush. Your system should generate demand forecasts based on last season's orders. "Last long rains, you sold 500 bags of DAP. This season you have 200 in stock. Order 300 more."
- Pre-orders. Allow farmers to place orders before the season and pay when the stock arrives. This gives the agrovet a demand signal for how much to order from suppliers.
- Bulk SMS campaigns. Two weeks before planting season, send a message to all registered farmers listing the available inputs and prices. Include a prompt to place orders early to secure stock.
- System load. Your payment system needs to handle the spike. If 200 farmers all try to pay via M-Pesa on the same morning, your webhook handler needs to keep up. Use a queue (BullMQ, for example) to process webhook events rather than handling them synchronously.
Off-season is not dead time. Farmers buy animal feed, pesticides for storage, and tools year-round. Credit repayments also come in during harvest season. But the volume is much lower. Plan your infrastructure and your agrovet's cash flow accordingly.
Learn to Build Agritech Payment Systems
Agriculture is the backbone of the Kenyan economy, and every agricultural supply chain needs payment infrastructure. This is not a niche problem. It is a massive market that most tech platforms have barely touched outside Nairobi.
The McTaba M-Pesa Integration course (KES 9,999) teaches you the payment patterns at the core of this system: STK push, webhooks, transfers, 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 complete engineering skillset: databases, APIs, SMS integration, deployment, and building production systems that work in the real conditions of Kenyan infrastructure.
Key Takeaways
- ✓M-Pesa is the only payment method that matters for most Kenyan farmers. Build your entire payment flow around STK push. Card payments are a secondary option for institutional buyers.
- ✓SMS confirmations are essential because many farmers use feature phones or have limited data. Every order confirmation, payment receipt, and delivery update should go out as an SMS.
- ✓Group purchasing is a core feature, not a nice-to-have. Farmer groups buy fertilizer and seeds in bulk to get better prices. Your system needs to track individual contributions toward a group order.
- ✓Credit and loan integration lets farmers order inputs before harvest and pay after. This can be as simple as a credit limit per farmer approved by the agrovet, or as complex as integration with agricultural lending platforms.
- ✓Seasonal ordering patterns mean your system will have huge spikes before planting season and quiet periods in between. Design for this uneven load.
- ✓Use Paystack Transfers to pay suppliers. When the agrovet sources inputs from manufacturers or distributors, the payout can be automated from the Paystack balance.
Frequently Asked Questions
- Can farmers pay for inputs via M-Pesa through this system?
- Yes. M-Pesa is the primary payment method. When a farmer places an order, the system sends an STK push to their phone via the Paystack Charge API. The farmer enters their M-Pesa PIN and the payment is processed. An SMS confirmation is sent immediately after the payment succeeds.
- How does group purchasing work?
- A group chair creates a group order specifying the product and total quantity. Each member is assigned their share. Members pay individually via M-Pesa. The system tracks individual contributions. When the total amount is reached, the agrovet is notified to place the order with the supplier. Each member receives SMS updates as the order progresses.
- Can the agrovet extend credit to farmers?
- Yes. The agrovet sets a credit limit per farmer. Farmers can place orders on credit up to their limit. The system tracks outstanding credit balances and sends SMS reminders before the due date. When the farmer is ready to repay, they pay via M-Pesa. Credit repayment reduces the farmer's used credit and frees up their limit for future orders.
- How are suppliers paid through this system?
- Using Paystack Transfers. The system creates a transfer recipient for each supplier (bank account or M-Pesa number). When the agrovet needs to pay a supplier, the system initiates a transfer from the Paystack balance. The supplier receives the funds in their bank account or M-Pesa wallet.
- What if a farmer does not have a smartphone?
- The system is designed for this reality. M-Pesa STK push works on feature phones. All order confirmations, payment receipts, and delivery updates are sent via SMS, not through a web portal or app. The farmer never needs to open a browser. Registration can happen at the agrovet counter where the attendant enters the farmer's details.
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