Collect M-Pesa, Mixx by Yas, Airtel Money and HaloPesa payments from your server.
- Base URL:
https://kashlipa.com/api/v1 - Format: JSON in, JSON out, UTF-8
- Money: whole Tanzanian shillings as integers (
25000= TSh 25,000) - Times: ISO 8601 with offset, East Africa Time (
2026-10-06T14:05:12+03:00)
Authentication
Send your secret key as a Bearer token:
Authorization: Bearer kl_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Every key is live: real USSD prompts and real money. There is no test mode.
How to get a key:
- Verify your business in the dashboard.
- Under Developers › API keys, request API access and say what you will build.
- Once KashLipa approves it, generate a key. You can have up to 10 keys, each with its own ID. Delete any key you no longer use; requests with it are rejected straight away.
The secret is shown once. Keep it on your server only, never in a website, mobile app or public repository.
Responses
Every response has the same envelope:
{ "success": true, "data": { }, "error": null }
{
"success": false,
"data": null,
"error": {
"code": "validation_error",
"message": "Amount is required.",
"fields": { "amount": "Amount is required." }
}
}
Each response carries an X-Request-Id header. Quote it when you contact support.
Error codes
| HTTP | error.code | Meaning |
|---|---|---|
| 400 | invalid_idempotency_key | The Idempotency-Key header is not valid |
| 401 | missing_api_key | No Authorization: Bearer header |
| 401 | invalid_api_key | Key is wrong or revoked |
| 403 | ip_not_allowed | Request IP is not on the key's allow-list |
| 403 | api_access_not_approved | API access has not been approved for your account |
| 403 | merchant_suspended | Account suspended; reads still work |
| 404 | payment_not_found | No such payment in this mode |
| 404 | payment_link_not_found | No such payment link in this mode |
| 409 | request_in_progress | Same Idempotency-Key is still running; retry in a moment |
| 422 | validation_error | See error.fields |
| 422 | idempotency_key_reused | Same key, different request body |
| 422 | invalid_phone | Not a valid Tanzanian mobile number |
| 422 | channel_unavailable | Network not recognised or turned off |
| 422 | amount_too_low / amount_too_high | Outside the network's limits (see GET /channels) |
| 429 | rate_limited | Too many requests this minute; wait for Retry-After seconds |
| 500 | server_error | Our fault. Safe to retry with the same Idempotency-Key |
Rate limits
120 requests per minute per key by default. Every response includes X-RateLimit-Limit and X-RateLimit-Remaining; a 429 includes Retry-After.
Idempotency
Add an Idempotency-Key header to any POST (your order number works well, up to 100 characters). If the connection drops and you retry with the same key and body, you get the original response back, with Idempotent-Replayed: true, instead of a second payment. Keys are kept for 24 hours.
Payments
The payment object
{
"id": "txn_8fK2mQ9xZr41LpVt0aBc",
"object": "payment",
"mode": "live",
"status": "successful",
"amount": 25000,
"fee": 625,
"net_amount": 24375,
"currency": "TZS",
"channel": "mpesa",
"phone": "255754123456",
"reference": "ORDER-1042",
"description": "Order #1042",
"customer": { "name": "Asha Mwinyi", "email": null },
"metadata": { "order_id": "1042" },
"failure": null,
"network_transaction_id": "9JK3XQ2LMN",
"payment_link": null,
"created_at": "2026-10-06T14:05:12+03:00",
"expires_at": "2026-10-06T14:35:12+03:00",
"completed_at": "2026-10-06T14:05:41+03:00",
"available_at": "2026-10-07T14:05:41+03:00"
}
| Status | Meaning |
|---|---|
pending | Created, prompt not yet confirmed as sent |
processing | USSD prompt is on the customer's phone |
successful | Paid. The only status that means you have the money |
failed | Declined, insufficient balance, or the network rejected the prompt (failure explains) |
expired | Not paid within 30 minutes |
reversed | Reversed by KashLipa after investigation |
available_at equals completed_at: paid money is in your available balance and can be withdrawn straight away.
Create a payment
POST /payments
Sends a USSD prompt to the customer's phone. They approve with their mobile money PIN.
| Field | Type | Required | Notes |
|---|---|---|---|
amount | integer | yes | TSh, within the network's limits |
phone | string | yes | Any format: 0754123456, +255 754 123 456, 255754123456 |
channel | string | no | mpesa, mixx, airtel, halopesa. Detected from the number if omitted; set it for numbers moved between networks |
reference | string | no | Your order or invoice number, up to 100 chars |
description | string | no | Up to 255 chars |
customer_name | string | no | |
customer_email | string | no | |
callback_url | string | no | Webhooks for this payment are also sent here. HTTPS in live mode |
metadata | object | no | Up to 20 keys; values are strings, numbers or booleans up to 500 chars |
currency | string | no | Only TZS |
curl https://kashlipa.com/api/v1/payments \
-H "Authorization: Bearer $KASHLIPA_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042" \
-d '{
"amount": 25000,
"phone": "0754123456",
"reference": "ORDER-1042",
"description": "Order #1042",
"callback_url": "https://yourshop.co.tz/kashlipa/webhook",
"metadata": { "order_id": "1042" }
}'
Response 201 Created: a payment object, usually with "status": "processing".
<?php
$ch = curl_init('https://kashlipa.com/api/v1/payments');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . getenv('KASHLIPA_SECRET_KEY'),
'Content-Type: application/json',
'Idempotency-Key: order-1042',
],
CURLOPT_POSTFIELDS => json_encode(['amount' => 25000, 'phone' => '0754123456', 'reference' => 'ORDER-1042']),
]);
$response = json_decode(curl_exec($ch), true);
curl_close($ch);
// Node.js 18+
const res = await fetch('https://kashlipa.com/api/v1/payments', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.KASHLIPA_SECRET_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'order-1042',
},
body: JSON.stringify({ amount: 25000, phone: '0754123456', reference: 'ORDER-1042' }),
});
const { success, data, error } = await res.json();
import os, requests
r = requests.post(
"https://kashlipa.com/api/v1/payments",
headers={"Authorization": f"Bearer {os.environ['KASHLIPA_SECRET_KEY']}", "Idempotency-Key": "order-1042"},
json={"amount": 25000, "phone": "0754123456", "reference": "ORDER-1042"},
timeout=30,
)
print(r.json())
Get a payment
GET /payments/{id}
Returns the payment object. Polling an open payment every 5 to 10 seconds is fine: the status changes the moment the network confirms the payment. (Following Selcom's rules, we only ask Selcom directly about a payment 3 minutes after the prompt, then at most every 3 minutes.) Webhooks are better.
List payments
GET /payments?status=successful&from=2026-10-01&to=2026-10-31&page=1&limit=50
| Query | Notes |
|---|---|
status | One of the statuses above |
reference | Exact match |
from, to | Date (2026-10-01, to includes the whole day) or ISO 8601 timestamp |
page | From 1 |
limit | 1–100, default 20 |
{
"success": true,
"data": {
"items": [ { "id": "txn_...", "object": "payment" } ],
"pagination": { "page": 1, "limit": 50, "total": 132, "has_more": true }
},
"error": null
}
Payment links
A payment link is a hosted page (https://kashlipa.com/pay/{id}) where a customer enters their number and pays. Good for WhatsApp, Instagram, SMS, and for sites without a backend.
Create a payment link
POST /payment-links
| Field | Type | Notes |
|---|---|---|
title | string | Required. What the customer is paying for |
description | string | Optional |
amount | integer | Fixed amount. Leave out to let the customer enter one |
min_amount | integer | With an open amount, the lowest accepted |
reusable | boolean | Default true. false means it can be paid once |
max_payments | integer | Stop after this many payments |
collect_name | boolean | Default true |
collect_email | boolean | Default false |
redirect_url | string | HTTPS. After payment the customer can continue here, with ?payment=txn_…&status=… |
success_message | string | Shown after payment |
expires_at | string | Date or ISO 8601 timestamp |
{
"success": true,
"data": {
"id": "term-1-school-fees-k7m2q",
"object": "payment_link",
"mode": "live",
"url": "https://kashlipa.com/pay/term-1-school-fees-k7m2q",
"title": "Term 1 school fees",
"amount": 350000,
"reusable": true,
"payments_count": 0,
"total_collected": 0,
"status": "active"
},
"error": null
}
GET /payment-links/{id} and GET /payment-links (paginated like payments) are also available.
Pay box for landing pages (no server needed)
Take payments right on your own page: the customer sees the service and price, types only their phone number and approves the prompt on their phone. No API key is used, so it is safe to put on a static landing page.
- Create a payment link in the dashboard (fixed price, or let the customer type the amount).
- Paste this where the box should appear:
<div data-kashlipa-pay="term-1-school-fees-k7m2q"></div>
<script src="https://kashlipa.com/assets/js/pay.js" async></script>
The customer can type their number any way they know it: 0754 123 456, 0654123456, 754123456 or +255 754 123 456. It is converted to 255754123456 automatically and the network (M-Pesa, Mixx by Yas, Airtel Money, HaloPesa) is detected from the number.
Options (attributes on the div):
| Attribute | What it does |
|---|---|
data-reference | Your own order or invoice number, saved on the payment (ORDER-1042) |
data-lang | sw for Kiswahili text (default English) |
data-color | Button colour, e.g. #0F766E |
data-redirect | Page to open after a successful payment; ?payment=txn_…&status=successful is added |
The box fires kashlipa:paid and kashlipa:failed events on the div (event.detail has the payment id, status, amount and reference):
<script>
document.querySelector('[data-kashlipa-pay]').addEventListener('kashlipa:paid', function (e) {
console.log('Paid', e.detail.id, e.detail.amount);
});
</script>
Always confirm a payment on your server (webhook payment.succeeded or GET /payments/{id}) before delivering something valuable: anything in the browser can be faked.
If you have a server, you can also send the number straight to POST /payments in the same local format ("phone": "0754123456"); the API converts it the same way.
"Lipa na KashLipa" button
For a landing page, paste:
<a href="https://kashlipa.com/pay/term-1-school-fees-k7m2q" data-kashlipa-button>Lipa na KashLipa</a>
<script src="https://kashlipa.com/assets/js/button.js" async></script>
On a computer it opens the payment page in a small window; on a phone it opens normally. Add data-kashlipa-button="dark" for the dark style.
Account
Balance
GET /balance
Paid money goes straight to available. pending is kept for compatibility and is normally 0.
{ "success": true, "data": { "object": "balance", "mode": "live", "currency": "TZS", "available": 1250000, "pending": 0 }, "error": null }
Channels
GET /channels: the networks you can collect with, their limits, the number prefixes we use to detect them, and your API fee per payment (the rate KashLipa set when approving your API access).
{
"success": true,
"data": {
"items": [
{
"code": "mpesa",
"name": "Vodacom M-Pesa",
"type": "mobile_money",
"min_amount": 500,
"max_amount": 5000000,
"prefixes": ["074", "075", "076"],
"fee": { "percent": 10, "fixed": 0, "cap": null }
}
]
},
"error": null
}
Webhooks
Set an endpoint under Developers › Webhooks (one per mode). We POST JSON when a payment changes to a final state:
| Event | When |
|---|---|
payment.succeeded | The customer paid |
payment.failed | Declined or rejected |
payment.expired | Not paid in time |
{
"id": "evt_Q3m8ZkR2sV0pLx7nT1aY",
"type": "payment.succeeded",
"mode": "live",
"created_at": "2026-10-06T14:05:42+03:00",
"data": { "id": "txn_8fK2mQ9xZr41LpVt0aBc", "object": "payment", "status": "successful", "amount": 25000 }
}
data is the full payment object. A payment's callback_url, if set, receives the same events.
Headers:
Content-Type: application/json
User-Agent: KashLipa-Webhooks/1.0
X-KashLipa-Event: payment.succeeded
X-KashLipa-Delivery: evt_Q3m8ZkR2sV0pLx7nT1aY
X-KashLipa-Signature: t=1759748742,v1=5f2c9e...
Verify every webhook. v1 is the hex HMAC-SHA256 of t + "." + <raw request body> using your endpoint's signing secret. Reject the request if the signature doesn't match or t is more than 5 minutes old.
<?php
$payload = file_get_contents('php://input');
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_KASHLIPA_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $payload, getenv('KASHLIPA_WEBHOOK_SECRET'));
if (abs(time() - (int) ($sig['t'] ?? 0)) > 300 || !hash_equals($expected, $sig['v1'] ?? '')) {
http_response_code(400);
exit;
}
$event = json_decode($payload, true);
// Handle $event['type'] idempotently: the same event can arrive more than once.
http_response_code(200);
Reply with any 2xx within 10 seconds. Otherwise we retry after 1, 5, 15 and 30 minutes, then 1, 3, 6 and 12 hours. Every attempt is listed in the dashboard with the response you returned, and you can resend any delivery.