How Malipo fits into your application
Malipo sits between your server and the payment providers. Your server asks it for a payment, a refund or a payout, and hears by webhook how it went. Your customers meet it only on the hosted checkout page and in the prompt on their phone.
At a glance
- Base URL
https://malipo.kweepa.com/ api/ v1 - Authentication
Authorization: Bearer mlp_test_…ormlp_live_…. The key decides the mode.- Format
- JSON. Amounts are in minor units:
150000is KES 1,500.00. - Safe to repeat
- An
Idempotency-Keyis required on every request that moves money. - Webhooks
- Signed with HMAC-SHA256, tried again for up to 10 attempts.
- Errors
- RFC 9457 problem details.
-
Your server asks
POST /with the amount, the method, a reference and anpayments Idempotency-Key. -
Malipo prompts the customer
A prompt on the phone for M-Pesa and Airtel Money; Jenga's card page for a card.
-
The customer approves
They enter their PIN on the phone, or pay on the card page.
-
The provider answers
Malipo does not take a callback on trust: it asks the provider how the payment stands, and that answer settles it.
-
Malipo books it
The payment ends in a status. A payment that succeeded goes in the app's ledger.
-
Your server hears
A signed webhook, such as
payment.succeeded, is posted to your endpoint.
The words the guide uses
- App
- One application that takes money through Malipo. Its payments, refunds, payouts, balance, events, webhook endpoints and keys are its own, and invisible to every other app.
- Mode
- Every app has a test mode and a live mode. Test moves no money: it goes through a built-in simulator, or a provider's sandbox. Live moves real money, once Kweepa has let the app go live.
- Route
- Says which provider account an app's payments of one method and mode go through: the Kweepa paybill everybody shares, or the app's own. Only Kweepa's administrators see provider accounts and their secrets.
- API key
- A secret for your server,
mlp_test_…ormlp_live_…. Daraja, Airtel and Jenga credentials are never in your code or on your servers: they stay sealed in Malipo. - Webhook
- How Malipo tells your server what happened, signed so that you can check it came from Malipo.
What changes for your application
- It holds one API key and one webhook secret, instead of consumer keys, passkeys, client secrets, PINs and private keys.
- It never sees a card. A customer pays on Jenga's card page, so card details stay out of your systems altogether.
- It hears of every payment, refund and payout by one signed webhook, instead of a callback for each provider, each with its own shape.
- It can test everything without provider credentials, through the simulator, from the day it is signed up.
Get an app and an API key
-
Make the app
Sign in at
https://with your Kweepa account and press New app. Anybody signed in can make an app, up to five, and becomes its owner. It can take test payments at once, and has a first test key.malipo.kweepa.com/ dashboard/ Or have it made for you
Kweepa's administrators can make an app and name its owner, if you would rather not sign up yourself.
-
Keep the test key
The key is shown once, and only its hash is kept. Put it in your server's environment, beside the address and, soon, the webhook secret:
MALIPO_URL=https://malipo.kweepa.com MALIPO_API_KEY=mlp_test_... # mlp_live_... once the app is live MALIPO_WEBHOOK_SECRET=whsec_... -
Send it with every request
Every request carries
Authorization: Bearer <key>. Your app's key opens/for your app, in the key's mode.api/ v1/ **
Keys are for servers
Keep them out of browsers and phone apps. A customer pays on the hosted checkout page, which needs no key. Malipo sends no CORS headers to a page of another site.
What to know about keys
- The key decides the mode. A test key only ever sees and makes test objects; a live key only live ones. There is no
livemodefield to get wrong. - A key that is lost is revoked and replaced, not recovered. Make another, deploy it, then revoke the old one in the dashboard.
- Live keys can only be made for a live app, by an owner of it. When an app stops being live, its live keys are revoked with it. A suspended app's keys open nothing.
- Requests with an app's key are held to 200 at once and 1,200 a minute.
Who can do what in the dashboard
- Owners
- Change the app, its team and its keys, refund and pay out live money, and ask to go live.
- Developers
- Make test keys and webhook endpoints, and refund and pay out test money.
- Viewers
- Look.
Take a payment
A payment is the same request whether M-Pesa, Airtel Money or a card carries it.
curl
curl -s -X POST https://malipo.kweepa.com/api/v1/payments \
-H "Authorization: Bearer $MALIPO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-10247-1" \
-d '{"method":"MPESA","amount":150000,"currency":"KES",
"phone":"0712345678","reference":"ORDER-10247"}'
Java (Spring)
RestClient malipo = RestClient.builder()
.baseUrl(url + "/api/v1")
.defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey)
.build();
record NextAction(String type, String url, String message) {}
record Payment(String id, String status, String method, long amount,
String currency, String reference, String receipt,
String failureCode, String failureMessage,
NextAction nextAction) {}
Payment payment = malipo.post().uri("/payments")
.header("Idempotency-Key", "order-10247-1")
.body(Map.of("method", "MPESA", "amount", 150000,
"currency", "KES", "phone", "0712345678",
"reference", "ORDER-10247"))
.retrieve().body(Payment.class);
The answer is PROCESSING, with nextAction.type set to APPROVE_ON_PHONE: the prompt is on the customer's phone. Tell them to enter their PIN, then wait for the webhook, or ask for the payment:
POST /payments
{"id":"2610191649...","status":"PROCESSING","nextAction":{"type":"APPROVE_ON_PHONE",...},...}
GET /payments/2610191649... (a few seconds later)
{"status":"SUCCEEDED","receipt":"S8K2M1Q0ZP",...}
The request
| Field | What it is |
|---|---|
method |
MPESA, AIRTEL_MONEY or CARD. |
amount |
In minor units: 150000 is KES 1,500.00. Mobile money takes whole shillings (a multiple of 100), KES 1 to 250,000 unless the account says otherwise. |
currency |
KES for mobile money. A card also takes what its Jenga merchant takes (KES and USD by default). |
phone |
For mobile money: 0712345678, 712345678, 254712345678 or +254712345678. |
reference |
Your own: an order or invoice number. Shown in the dashboard and searchable. |
description, customer |
What the customer and your people are shown. |
returnUrl |
For CARD: where the customer's browser is sent back to once they have paid or given up. |
accountReference |
For M-Pesa: what the prompt shows as the account, 12 letters and digits. Otherwise the app's prefix and your reference. |
metadata |
Your own JSON, up to 4 KB, handed back as it was in every answer and webhook. |
Make it safe to send again
Idempotency-Key is required. Send a value that is unique to what is being paid for. The same key with the same request answers with the payment the first one made, 200 with Idempotent-Replayed: true, and charges nothing again, so a request that timed out can always be sent again. The same key with a different request is refused, 409.
- A payment
- One key for each thing being paid for and each attempt:
order-10247-1. If the customer gives up on a prompt and tries again, that is a new attempt:order-10247-2. - A refund
- The refund's own:
refund-order-10247. - A payout
- The thing it pays for:
rider-552-2026-10-19.
How a payment goes
-
PENDING
Asked for and not yet sent to the provider.
-
PROCESSING
With the provider, or waiting for the customer.
It ends in one of
-
SUCCEEDED
The money arrived.
receiptis the provider's: the M-Pesa receipt number, the Airtel Money id or the card receipt. -
FAILED
It did not go through, with a
failureCodesuch asinsufficient_funds,wrong_pin,customer_busyordeclined. -
CANCELLED
The customer said no.
-
EXPIRED
Nobody answered in time: 3 minutes for mobile money, 30 for cards.
- Mobile money
- The answer is
PROCESSINGwithnextAction.typeAPPROVE_ON_PHONE. - Card
- The answer holds
nextAction.typeREDIRECTandnextAction.url, Jenga's card page: send the customer's browser there. They come back to yourreturnUrlwithpayment_idadded. Their coming back says nothing about how it went: the payment's status and its webhook do.
A payment that succeeds late still succeeds
Money that arrives after a payment expired, or after its provider first said it failed, is money: the payment becomes SUCCEEDED and you get payment.succeeded. Act on the status the payment has, not on the order events arrive in.
Find payments again
GET /api/v1/payments?status=SUCCEEDED&method=MPESA&q=ORDER-10247&from=2026-10-01T00:00:00Z&limit=50
GET /api/v1/payments/{paymentId}
Lists are newest first, limit up to 100. nextCursor, with hasMore, is the cursor of the next page. q finds an id, your reference, a receipt or a phone number.
Send customers to a hosted checkout page
The simplest integration: no payment form, no phone number to validate, no card page to send anybody to.
curl -s -X POST https://malipo.kweepa.com/api/v1/checkout-sessions \
-H "Authorization: Bearer $MALIPO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"amount":250000,"currency":"KES","reference":"ORDER-10248",
"description":"Family meal","customer":{"phone":"0712345678"},
"successUrl":"https://foodtogo.co.ke/orders/10248/paid",
"cancelUrl":"https://foodtogo.co.ke/cart"}'
{"id":"…","url":"https://malipo.kweepa.com/checkout/Qm9…","status":"OPEN",
"methods":["MPESA","AIRTEL_MONEY","CARD"],…}
Send the customer to url. The page shows your app's name, logo and colour, the amount and the ways to pay (methods, or every way the app can take that amount). The customer picks one, approves it on their phone or pays on the card page, and is sent to successUrl with session_id added. You hear checkout.completed, with the session and its paymentId, by webhook.
- A page takes one payment in flight at a time and 8 attempts in all, and stays open an hour unless
expiresInMinutessays otherwise (5 to 1440). An unpaid page expires, and you hearcheckout.expired. - A payment that succeeds after its page expired completes it all the same.
POST /closes a page nobody is paying on.api/ v1/ checkout-sessions/ {checkoutSessionId}/ cancel - Pass an
Idempotency-Keyto open a page safely twice. It is optional here, since a page moves no money.
Hear about every payment
-
Register an endpoint
Tell Malipo where your server listens, in the dashboard or with the app's key. The endpoint belongs to the key's mode: test events go to test endpoints only, live events to live ones.
curl -s -X POST https://malipo.kweepa.com/api/v1/webhook-endpoints \ -H "Authorization: Bearer $MALIPO_API_KEY" \ -H "Content-Type: application/json" \ -d '{"url":"https://api.foodtogo.co.ke/hooks/malipo","eventTypes":["*"]}'The answer holds the endpoint's signing
secret(whsec_…). The dashboard shows it again on purpose. The address must behttpsand on the public internet; redirects are not followed. -
Check the signature before anything else
v1is the HMAC-SHA256, in hex, oft, a dot, and the body exactly as it arrived, under the endpoint's secret. Work it out over the raw bytes, before any JSON parsing, and turn away what is more than five minutes old.Java
static boolean verify(byte[] rawBody, String header, String secret) throws GeneralSecurityException { Map<String, String> parts = new HashMap<>(); for (String part : header.split(",")) parts.put(part.substring(0, part.indexOf('=')), part.substring(part.indexOf('=') + 1)); long timestamp = Long.parseLong(parts.get("t")); if (Math.abs(Instant.now().getEpochSecond() - timestamp) > 300) return false; Mac hmacSha256 = Mac.getInstance("HmacSHA256"); hmacSha256.init(new SecretKeySpec( secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); hmacSha256.update((timestamp + ".").getBytes(StandardCharsets.UTF_8)); byte[] expected = HexFormat.of().formatHex(hmacSha256.doFinal(rawBody)) .getBytes(StandardCharsets.UTF_8); return MessageDigest.isEqual(expected, parts.get("v1").getBytes(StandardCharsets.UTF_8)); }Node
// Express: app.post('/hooks/malipo', express.raw({ type: 'application/json' }), handler) const crypto = require('crypto'); function verify(rawBody, header, secret) { const { t: timestamp, v1: signature } = Object.fromEntries( header.split(',').map((part) => part.split('='))); if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const expected = crypto.createHmac('sha256', secret) .update(timestamp + '.').update(rawBody).digest('hex'); return signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)); } -
Act on the status, once for each event
In one transaction: skip the event if its id is stored already, store the id, and set the order's payment state from
data.object.status(SUCCEEDEDmeans paid, whatever it was before). Keep Malipo's payment id on the order (payment.id, or a checkout session'spaymentId) so that a refund can name it. Answer2xxwithin 10 seconds, and do slow work after answering.
The webhook path is open to the internet
It takes no session and no CSRF token: the signature is the authentication. Serve it over https.
The events
| Event | When |
|---|---|
payment.succeeded |
The money arrived. It may come for a payment that had failed or expired: the money came late. |
payment.failed, payment.cancelled, payment.expired |
It ended without money. |
refund.succeeded, refund.failed |
How a refund went. |
payout.succeeded, payout.failed |
How a payout went. |
checkout.completed, checkout.expired |
A checkout page was paid, or ran out. |
webhook.test |
Sent from the dashboard, or by POST /. |
What a delivery looks like
POST /hooks/malipo
Content-Type: application/json
User-Agent: Malipo-Webhooks/1.0
X-Malipo-Event: payment.succeeded
X-Malipo-Event-Id: 2610191650042A1B2C3D4E5F
X-Malipo-Delivery: 2610191650047F6E5D4C3B2A
X-Malipo-Signature: t=1792432204,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
{ "id": "2610191650042A1B2C3D4E5F", "object": "event", "type": "payment.succeeded",
"livemode": true, "app": "foodtogo", "createdAt": "2026-10-19T16:50:04.402Z",
"data": { "object": { "id": "2610191649…", "object": "payment", "status": "SUCCEEDED",
"amount": 150000, "currency": "KES", "reference": "ORDER-10247",
"receipt": "SJK7RT61SV", … } } }
data.object is exactly what GET of that payment, refund, payout or session answers.
What to count on
- Answer
2xxwithin 10 seconds. Anything else is tried again with a pause that doubles from 30 seconds up to 6 hours, 10 attempts in all. Then it is given up and shown to Kweepa's administrators, and can be sent again. - A delivery may arrive more than once: drop event ids you have seen.
- Deliveries are not ordered. Act on the
statusof the object an event carries: apayment.succeededcan come after apayment.expiredof the same payment, and the payment is then paid. - An event is queued in the transaction of the change it reports: you never hear of something that was rolled back, and never miss something that was not.
- Missed some while your server was down? They are all in
GET /.api/ v1/ events
Refund a payment
curl -s -X POST https://malipo.kweepa.com/api/v1/refunds \
-H "Authorization: Bearer $MALIPO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: refund-order-10247" \
-d '{"paymentId":"2610191649…","reason":"Order cancelled"}'
- Money goes back the way it came: an M-Pesa reversal, an Airtel Money refund, a card refund.
- M-Pesa and Airtel Money give back all of a payment or nothing: leave
amountout. A card payment may be refunded in parts, up to what is left of it. - The amount is taken from the app's balance when the refund is asked for. A balance that does not cover it refuses the refund (
422,insufficient-balance). A refund that fails gives the money back to the balance and makes the payment refundable again, and you hearrefund.failed. - Statuses:
PENDING,PROCESSING, thenSUCCEEDEDorFAILED. A refund its provider never answers for staysPROCESSINGand is put in front of an administrator. It is not failed by the clock, which could refund twice. GET /lists the refunds of a payment.api/ v1/ refunds?paymentId=…
Pay money out
Money from the app's balance to an M-Pesa (B2C) or Airtel Money wallet: winnings, a rider's pay, a refund of more than a payment.
curl -s -X POST https://malipo.kweepa.com/api/v1/payouts \
-H "Authorization: Bearer $MALIPO_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: rider-552-2026-10-19" \
-d '{"method":"MPESA","amount":120000,"currency":"KES","phone":"0722000111",
"name":"Otieno","reference":"RIDER-552"}'
- Live payouts need Kweepa's leave for the app, and stop at the app's limit for the Nairobi day (
422, withdailyLimitandpaidToday). Test payouts need nothing. - Like a refund, the amount is taken from the balance at once and returned if the payout fails.
- KES 10 to 250,000 a payout unless the provider account says otherwise, in whole shillings.
- A payout whose result never comes (a timeout in Daraja's queue, an ambiguous Airtel answer) stays
PROCESSINGuntil an administrator settles it from the provider's portal. Sending money twice is worse than waiting.
Read your balance and ledger
GET /api/v1/balance
[{"currency":"KES","livemode":true,"available":1835000,...}]
GET /api/v1/ledger?currency=KES&limit=50
every movement, newest first, with the balance it left
| Kind | What it is |
|---|---|
PAYMENT |
A payment succeeded: plus its amount. |
REFUND, REFUND_REVERSAL |
A refund was asked for: minus. It failed: plus. |
PAYOUT, PAYOUT_REVERSAL |
A payout was asked for: minus. It failed: plus. |
ADJUSTMENT |
An administrator's entry, with a note: a settlement paid to the app's bank, a correction. |
The balance is what the app may refund and pay out. Live entries are never changed or deleted, by the application or by anybody with SQL (a trigger refuses it): a mistake is set right by another entry.
Test without credentials
A new app's test routes go to the simulator, which is Malipo pretending to be a provider. A simulated customer answers after 4 seconds, and how depends on the end of their phone number.
| Phone ends in | What happens |
|---|---|
…1032 |
The customer cancels: CANCELLED. |
…1037 |
Nobody answers: EXPIRED. |
…0001 |
The wallet is short: FAILED, insufficient_funds. |
…2001 |
The wrong PIN: FAILED, wrong_pin. |
| anything else | SUCCEEDED, with a receipt such as S8K2M1Q0ZP. |
- A test card payment's
nextAction.urlis the simulator's card page, with Approve and Decline. - Refunds always succeed. A payout to a number ending in
0001fails. - The simulator keeps the real rules: mobile money is refunded whole, amounts are whole shillings, the same limits apply.
- To test against a provider's own sandbox instead (Daraja's sandbox, Airtel's UAT, Jenga's UAT), Kweepa's administrators make a test provider account for it and point your app's test route at it.
Errors and limits
Errors are RFC 9457 problem details: match on type, show detail.
{ "type": "/errors/insufficient-balance", "title": "Unprocessable Content", "status": 422,
"detail": "The app's balance of KES 0.00 does not cover KES 1,000.00", "available": 0,
"currency": "KES", "trace_id": "9f2c41d07a6b3e58" }
| Status | When |
|---|---|
400 |
The request is malformed: a field missing or wrong, no Idempotency-Key. |
401 |
No credentials, or not accepted. A suspended app. |
403 |
The credentials do not open this. The app is not live. |
404 |
Not found, or not yours. |
409 |
An Idempotency-Key used for another request, a payment already in flight on a checkout page, or a state that does not allow it. |
422 |
Understood, and not possible: no route for the method, an amount out of bounds, a balance that does not cover it, a partial mobile money refund, a daily payout limit. |
429 |
Too many requests. Retry-After says when to come back. |
502, 503 |
Kweepa Identity, which signs people in to the dashboard, cannot be reached, or the server has no secrets key set. |
When a request that moves money fails or times out
Send the same request again with the same Idempotency-Key. It can never charge twice.
Go live
-
Test with the simulator, from the first day
Every flow, including the failures (phones ending
0001,1032,1037and2001) and the duplicates (send the same request twice). -
Test with the providers' sandboxes
Kweepa's administrators make test provider accounts with the sandbox credentials (Daraja sandbox, Airtel UAT, Jenga UAT) and point your test routes at them. Take a payment of each kind from the dashboard's test payment form.
-
Ask to go live
From the dashboard: your business name, a contact, and what the payments are for. Kweepa approves it and points your app's live routes at the live provider accounts, the shared Kweepa paybill or your own.
-
Make a live key and a live webhook endpoint
An owner makes the live key. Test and live endpoints are separate: register the live one too.
-
Take one small real payment, then refund it
Watch it in the dashboard, with what was said to the provider.
-
Switch your key
Change
MALIPO_API_KEYto the live key. Keep a test key for staging.
Where the money goes
Into the provider account your app's route names: for your business, your own paybill, till or card merchant account, so that the money lands with you. Malipo carries the requests, checks every payment with the provider and keeps the records.
Common questions
Where does the money go?
Into the provider account your app's route names: for your business, your own paybill, till or card merchant account, so that the money lands with you. Malipo carries the requests, checks every payment with the provider and keeps the records.
Can we try it without M-Pesa credentials?
Yes. A new app's test key works at once against Malipo's simulator, where phone numbers ending in certain digits succeed, run short of money, get a wrong PIN or cancel, so that every path can be tried. The providers' own sandboxes can be used too.
Do card numbers pass through our servers?
No. The customer types the card on Equity Bank's Jenga payment page, never on yours or on Malipo's, so card details stay out of your systems altogether.
How does an app go live?
It is tested with the simulator, then with the providers' sandboxes. Its owner asks to go live from the dashboard; once Kweepa approves it and sets its live routes, the owner makes a live key and a live webhook endpoint.
Ready to build with Malipo?
Tell us what your application sells and how your customers pay today. We will say how it fits and what to try first.