I'm documenting the build of Afrinex in public. This is Part 1.
If you've ever tried to integrate more than one payment provider in a Kenyan application, you already know the pain. If you haven't, let me walk you through it.
The Problem
Safaricom Daraja (M-Pesa) and KCB Buni are the two most important payment rails in the country. Together they cover the vast majority of digital transactions Kenyan businesses need to support. The problem is that under the hood, they have almost nothing in common.
Authentication is different. Daraja gives you a temporary OAuth token via Basic Auth. Buni uses standard OAuth2. Two token managers. Two refresh strategies.
Request payloads are different. The JSON you send to trigger an M-Pesa STK push looks nothing like what Buni expects for the same operation.
Webhook structures are different. Parsing a successful Daraja callback involves navigating a deeply nested object. Buni's is flatter but still proprietary.
Error formats are different. Each provider has its own error codes, its own message conventions, its own idea of what a "failed" response looks like.
The result is that every project ends up with duplicated, brittle integration code. You learn Daraja, then you learn Buni, and none of that knowledge transfers cleanly between them.
The Idea Behind Afrinex
The core philosophy is unification through abstraction.
Afrinex provides a single, consistent TypeScript interface that all payment providers implement. You learn one API. You handle one webhook contract. You get one set of strongly-typed error classes. The provider underneath is a plugin, not a hardcoded dependency.
// Same method. Same shape. Regardless of provider.
await pay.getProvider('daraja').stkPush({ phone, amount, reference, description });
await pay.getProvider('buni').stkPush({ phone, amount, reference, description });
Switching providers, or adding a new one, requires zero changes to your application code. You just implement the interface and register it.
How It Works
Afrinex is built around three core pieces:
1. The AfrinexClient
The central orchestrator. Created via createClient, it holds a registry of your instantiated providers, manages global config like your callbackUrl and environment, routes incoming webhooks to the right parser, and exposes an internal event emitter.
2. Providers
Independent classes (DarajaProvider, BuniProvider) that implement the underlying provider interfaces. Each one handles its own HTTP requests, token caching, and payload normalisation. From the outside, they all look the same.
3. Unified DTOs
Standardised request and response objects. When you call stkPush, you pass a generic request object. The provider translates it internally into whatever the upstream API expects. When the response comes back, it gets normalised into a standard shape before it ever reaches your code.
Getting Started
npm install afrinex
Here's a minimal setup:
import { createClient, DarajaProvider, BuniProvider } from 'afrinex';
const daraja = new DarajaProvider({
consumerKey: process.env.AFRINEX_DARAJA_CONSUMER_KEY!,
consumerSecret: process.env.AFRINEX_DARAJA_CONSUMER_SECRET!,
shortcode: process.env.AFRINEX_DARAJA_SHORTCODE!,
passkey: process.env.AFRINEX_DARAJA_PASSKEY!,
initiatorName: process.env.AFRINEX_DARAJA_INITIATOR_NAME!,
initiatorPassword: process.env.AFRINEX_DARAJA_INITIATOR_PASSWORD!,
}, 'sandbox');
const buni = new BuniProvider({
consumerKey: process.env.AFRINEX_BUNI_CONSUMER_KEY!,
consumerSecret: process.env.AFRINEX_BUNI_CONSUMER_SECRET!,
orgShortCode: process.env.AFRINEX_BUNI_ORG_SHORT_CODE!,
}, 'sandbox');
const pay = createClient({
env: 'sandbox',
callbackUrl: 'https://api.yourdomain.com/webhooks',
providers: { daraja, buni }
});
Triggering a Payment
const res = await pay.getProvider('daraja').stkPush({
phone: '254708374149',
amount: 1,
reference: `INV-${Date.now()}`,
description: 'Payment for services'
});
console.log('Checkout ID:', res.transactionId);
Handling Webhooks
Both Daraja and Buni send async HTTP callbacks after a transaction completes. Instead of writing two separate parsers, Afrinex exposes a single handleWebhook method that normalises both into a standard UnifiedWebhookPayload:
// Express.js example
app.post('/webhooks/:provider', (req, res) => {
const providerName = req.params.provider as 'daraja' | 'buni';
try {
const event = pay.handleWebhook(providerName, req.body);
if (event.event === 'payment.success') {
console.log(`Received KES ${event.amount} from ${event.phone}`);
// Update your database, send a receipt, etc.
}
res.sendStatus(200);
} catch (error) {
console.error('Webhook processing failed:', error);
res.sendStatus(400);
}
});
Typed Errors
No more guessing what a provider returned:
import { ProviderError } from 'afrinex';
try {
await pay.getProvider('buni').transfers.toPhone({ ... });
} catch (error) {
if (error instanceof ProviderError) {
console.error(`${error.providerMessage} (Code: ${error.providerCode})`);
}
}
What's on Top of the SDK
The SDK is the foundation, but it's not the whole picture.
The monorepo also ships @afrinex/agent, a LangGraph-powered AI layer that lets users interact with their payment accounts in plain language:
"Send 500 shillings to 0712 345 678 for rent"
The agent understands the intent, maps it to the right SDK method, and executes it, with built-in Human-In-The-Loop guardrails that require confirmation before any large transfer goes through. It's designed to be dropped into a Telegram bot, a WhatsApp integration, or any conversational interface.
npm install @afrinex/agent
Sandbox Gotchas (Save Yourself Some Time)
The sandbox environments for both providers have some sharp edges worth knowing about before you start testing.
Buni: Account not whitelisted for FT API
If you try to call transfers.toPhone() in the Buni sandbox, you'll likely hit a 406 error with code 900908. This is expected. The FT (Funds Transfer) API requires explicit whitelisting. Email buni@kcbgroup.com with your developer username and app name and ask to be whitelisted for the sandbox FT API.
Buni: 500 errors on payment queries
The Buni sandbox vending gateway is unstable when resolving STK transaction statuses. Afrinex formats the payload correctly. The issue is on their side. This doesn't affect production.
Daraja: Bad Request - Invalid Initiator
If you're hitting this on balances() or B2C endpoints, either your sandbox initiator credentials don't match the defaults, or you're missing the security certificate required to encrypt the initiator password. Use the official Daraja sandbox cert.
Daraja: Spike Arrest / 429
The sandbox rate-limits aggressively. If you're running a test loop against payments.query(), add a delay between calls.
What's Coming Next
Afrinex currently supports Daraja and Buni. The next providers on the roadmap are:
- Co-op Bank
- Equity Jenga
- Cooperative Bank
Each one will get its own part in this series. I'll document the API differences, the integration decisions, and anything interesting I find along the way.
Try It / Contribute
The library is open source and MIT licensed. If you're building in the Kenyan fintech space, or you just want to see how the provider pattern is implemented, the code is all there.
-
npm:
npm install afrinex - GitHub: github.com/Red-misst/afrinex
Pull requests are open. If you want to add a new provider, the contributor guide in the repo README walks you through exactly how to implement the interface.
I published the first version two days ago. It already has 750+ downloads. Clearly this problem needed solving.
Follow along for Part 2, where I start integrating Co-op Bank and document whatever their API throws at me.
Top comments (0)