API reference · v1

KashLipa API

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:

  1. Verify your business in the dashboard.
  2. Under Developers › API keys, request API access and say what you will build.
  3. 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

HTTPerror.codeMeaning
400invalid_idempotency_keyThe Idempotency-Key header is not valid
401missing_api_keyNo Authorization: Bearer header
401invalid_api_keyKey is wrong or revoked
403ip_not_allowedRequest IP is not on the key's allow-list
403api_access_not_approvedAPI access has not been approved for your account
403merchant_suspendedAccount suspended; reads still work
404payment_not_foundNo such payment in this mode
404payment_link_not_foundNo such payment link in this mode
409request_in_progressSame Idempotency-Key is still running; retry in a moment
422validation_errorSee error.fields
422idempotency_key_reusedSame key, different request body
422invalid_phoneNot a valid Tanzanian mobile number
422channel_unavailableNetwork not recognised or turned off
422amount_too_low / amount_too_highOutside the network's limits (see GET /channels)
429rate_limitedToo many requests this minute; wait for Retry-After seconds
500server_errorOur 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"
}
StatusMeaning
pendingCreated, prompt not yet confirmed as sent
processingUSSD prompt is on the customer's phone
successfulPaid. The only status that means you have the money
failedDeclined, insufficient balance, or the network rejected the prompt (failure explains)
expiredNot paid within 30 minutes
reversedReversed 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.

FieldTypeRequiredNotes
amountintegeryesTSh, within the network's limits
phonestringyesAny format: 0754123456, +255 754 123 456, 255754123456
channelstringnompesa, mixx, airtel, halopesa. Detected from the number if omitted; set it for numbers moved between networks
referencestringnoYour order or invoice number, up to 100 chars
descriptionstringnoUp to 255 chars
customer_namestringno
customer_emailstringno
callback_urlstringnoWebhooks for this payment are also sent here. HTTPS in live mode
metadataobjectnoUp to 20 keys; values are strings, numbers or booleans up to 500 chars
currencystringnoOnly 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

QueryNotes
statusOne of the statuses above
referenceExact match
from, toDate (2026-10-01, to includes the whole day) or ISO 8601 timestamp
pageFrom 1
limit1–100, default 20
{
  "success": true,
  "data": {
    "items": [ { "id": "txn_...", "object": "payment" } ],
    "pagination": { "page": 1, "limit": 50, "total": 132, "has_more": true }
  },
  "error": null
}

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.

POST /payment-links

FieldTypeNotes
titlestringRequired. What the customer is paying for
descriptionstringOptional
amountintegerFixed amount. Leave out to let the customer enter one
min_amountintegerWith an open amount, the lowest accepted
reusablebooleanDefault true. false means it can be paid once
max_paymentsintegerStop after this many payments
collect_namebooleanDefault true
collect_emailbooleanDefault false
redirect_urlstringHTTPS. After payment the customer can continue here, with ?payment=txn_…&status=…
success_messagestringShown after payment
expires_atstringDate 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.

  1. Create a payment link in the dashboard (fixed price, or let the customer type the amount).
  2. 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):

AttributeWhat it does
data-referenceYour own order or invoice number, saved on the payment (ORDER-1042)
data-langsw for Kiswahili text (default English)
data-colorButton colour, e.g. #0F766E
data-redirectPage 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:

EventWhen
payment.succeededThe customer paid
payment.failedDeclined or rejected
payment.expiredNot 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.