Connect your systems to Mali Yangu.
Read the applications sent to you, move them through your own process, and get a signed message the moment something happens. Plain JSON over https.
Three steps
- Upgrade to Premium or Enterprise, then open Developers in your portal.
- Create an API key and keep it on your server. It is shown once.
- Send it on every request as
Authorization: Bearer YOUR_KEY.
curl "https://api.example.com/api/v1/applications?status=submitted&limit=10" \
-H "Authorization: Bearer my_live_xxxxxxxx_xxxxxxxxxxxxxxxx"Keys have permissions: applications:read and applications:write. Give each system only what it needs, and revoke a key at any time.
Endpoints
Base address: https://api.example.com/api/v1
| Request | What it does | Key needs |
|---|---|---|
GET /me | Your company, plan and this month's usage | any key |
GET /applications | Your applications, newest first. Filter with status. Page with limit (up to 100) and cursor | applications:read |
GET /applications/{id} | One application | applications:read |
POST /applications/{id}/status | Move an application on. The customer is told, exactly as when you do it in the portal | applications:write |
Changing status
curl -X POST "https://api.example.com/api/v1/applications/APPLICATION_ID/status" \
-H "Authorization: Bearer YOUR_KEY" -H "Content-Type: application/json" \
-d '{ "status": "offer_sent", "notes": "Offer valid 7 days",
"offer": { "approvedAmount": 3000000, "approvedRate": 2.8, "approvedTenureMonths": 12 } }'Statuses: submitted, under_review, offer_sent, approved, rejected, disbursed, cancelled. Applications move forward only. A move that is not allowed returns a clear error.
An application
{
"id": "6ad7204111ce1767ae9291a0",
"status": "under_review",
"loanAmount": 3500000, "currency": "UGX", "tenureMonths": 12,
"interestRate": 2.8, "monthlyPayment": 336000,
"asset": { "name": "Motorcycle", "category": "motorcycle" },
"customer": {
"firstName": "Amina", "lastName": "N.", "phoneNumber": "+256771234567",
"kycScore": 78, "assessment": "passed", "creditRating": "fair"
},
"createdAt": "2026-10-20T09:12:00.000Z", "updatedAt": "2026-10-20T10:01:00.000Z"
}National ID numbers and photos are never sent through the API.
Get told, don't poll
Add an https address in your portal and we post to it when something happens. Each delivery is JSON with an event id, a name and the data.
{ "id": "evt_9f2c1b7a4d3e8a10", "event": "application.created",
"createdAt": "2026-10-20T09:12:00.000Z", "data": { ...an application, as above... } }| Event | Sent when |
|---|---|
application.created | A customer applies to you |
application.updated | An application changes status |
usage.near_limit | You reach 80% of your monthly allowance |
usage.limit_reached | You use your whole monthly allowance |
subscription.activated | A plan payment is confirmed |
subscription.expired | A paid plan ends |
Check every delivery is really from us
Each request carries an X-MaliYangu-Signature header such as t=1760951520,v1=ab12.... Compute an HMAC-SHA256 of t + "." + rawBody with your endpoint's signing secret and compare. Reject anything older than five minutes.
import crypto from 'node:crypto';
// rawBody must be the exact bytes received, before any JSON parsing.
export function isFromMaliYangu(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false; // too old
const expected = crypto.createHmac('sha256', secret).update(`${parts.t}.${rawBody}`).digest('hex');
const given = Buffer.from(parts.v1 || '', 'hex');
const wanted = Buffer.from(expected, 'hex');
return given.length === wanted.length && crypto.timingSafeEqual(given, wanted);
}- Answer with any
2xxwithin 8 seconds. Do slow work after replying. - If you do not, we try again twice, a few seconds apart, then record the delivery as failed. After 10 failures in a row the endpoint is paused and you are shown why.
- The same event can arrive more than once. Use
idto ignore repeats. - Deliveries are best effort. Call
GET /applicationsnow and then to catch up on anything missed. - Webhook addresses must be public https addresses.
When something goes wrong
{ "error": { "code": "invalid_api_key", "message": "That API key is not valid, or it has been revoked." } }| Status | code | Meaning |
|---|---|---|
| 401 | invalid_api_key | Missing, wrong or revoked key |
| 403 | missing_scope | The key lacks the permission for this call |
| 403 | plan_does_not_include_api | Your plan no longer includes the API |
| 400 | invalid_status, invalid_transition, offer_required | The request could not be applied |
| 404 | not_found | No such application on your account |
| 429 | rate_limited | Too many requests. Premium allows 60 a minute per key, Enterprise 300 |
Ready to connect?
The API is included on Premium and Enterprise. Need higher limits or a custom integration? Talk to us.