Build payments into anything
Collect MTN and Airtel mobile money, send payouts and embed a hosted checkout, with one simple JSON API. Every example below runs against the sandbox with a test key.
https://api.cashmora.co.ug/v1
#Introduction
Cashmora lets your website, app or system collect mobile money from MTN and Airtel customers in Uganda, send payouts to any MTN or Airtel number, and show a hosted checkout inside your own pages.
| Base URL | https://api.cashmora.co.ug/v1 |
| Format | JSON in, JSON out. Send Content-Type: application/json. |
| Authentication | Authorization: Bearer <secret key> on every request. |
| Amounts | Whole numbers in UGX (no decimals): 5000 is UGX 5,000. |
| Phone numbers | Any common format: 0772123456, 256772123456, +256 772 123 456. |
| Times | ISO 8601 in UTC, e.g. 2026-10-23T09:15:00Z. |
| Support | Quote the Cashmora-Request-Id response header when you contact us. |
Every successful response is {"status":"success","data":{…}}. Lists add "meta". Errors are {"status":"error","error":{"code":"…","message":"…","fields":{…}}}.
#Quickstart
Take a test payment in five minutes. No real money moves in sandbox.
- Create a Cashmora account and verify your email.
- Subscribe to Mobile Collections under Products (free in sandbox).
- Create a test key under Developers → API keys. It starts with
cm_test_sk_. - Request a payment from the test number
0770000001. It succeeds a few seconds later. - Add a webhook endpoint under Developers → Webhooks so your system hears about results without polling.
curl -X POST https://api.cashmora.co.ug/v1/collections \
-H "Authorization: Bearer cm_test_sk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"request_id":"3f8e4c1a-6b2d-4f7e-9a10-5c3b2e1d0f9a","phone":"0770000001","amount":5000,"reference":"ORDER-1042"}'When your business is verified and live, create a live key (cm_live_sk_) and change only the key on your server.
#Authentication
Send your secret key as a bearer token. Keys belong on your server only: never in a browser, a mobile app or a public repository. If a key leaks, revoke it under Developers → API keys; it stops working immediately.
| Key | Prefix | Use |
|---|---|---|
| Test | cm_test_sk_ | Sandbox: simulator and test numbers. No real money. |
| Live | cm_live_sk_ | Real money. Available once Cashmora enables live payments; creating one needs two-step verification. |
- Allow payouts: a key can only send money if this was ticked when it was created. Use collection-only keys wherever you can.
- Allowed IPs: optionally lock a key to your servers' addresses.
- The key decides the mode of each call (
sandboxorlive). Nothing else changes.
curl https://api.cashmora.co.ug/v1/balance -H "Authorization: Bearer cm_test_sk_YOUR_KEY"#Check your key
/v1/ping
no key needed
Connectivity check. Send your key to confirm it works and see its mode.
No key needed. If you send one, the answer says whether it is valid and its mode.
#Sandbox and live
New businesses start in sandbox. After verification (KYC), Cashmora enables live payments. Your test keys keep working after go-live, so developers can keep building safely.
- Test keys → sandbox: the simulator answers instead of MTN/Airtel, using the test numbers.
- Live keys → real money.
- Webhook endpoints, balances and transactions are separate for each mode, so test events never reach your live system.
#Test numbers
With a test key, these numbers force an outcome. Any other valid MTN or Airtel number succeeds after a few seconds.
| Number | Collections | Payouts |
|---|---|---|
0770000001 · 0700000001 | Approved after a few seconds | Delivered |
0770000002 · 0700000002 | Customer declines → failed | Not registered → failed, money returned |
0770000003 · 0700000003 | Never answered → expired | Slow network → delivered after about a minute |
0770000004 · 0700000004 | Times out → unknown → successful | Times out → unknown → delivered |
077… numbers are MTN, 070… numbers are Airtel. Both networks work in sandbox even while a network is under maintenance for live money.
#Request a payment
/v1/collections
Send a mobile money prompt to a customer's phone.
The customer approves with their PIN; you learn the result by webhook or with GET. The response comes back immediately with status pending.
successful. Treat unknown as "still waiting", never as failed.request_idstringrequiredYour unique id for this attempt (8–100 characters: letters, numbers,- _ . :; a UUID works). Retry with the same value; never reuse it for another payment. Also accepted as theIdempotency-Keyheader.phonestringrequiredCustomer's MTN or Airtel number.amountintegerrequiredUGX 500 – 5,000,000.referencestringoptionalYour order or invoice number (≤ 100). Can repeat across attempts and part payments.descriptionstringoptionalWhat the payment is for (≤ 255).payer_namestringoptionalCustomer's name, for your records (≤ 120).fee_paid_bystringoptionalmerchant(fee taken from what you receive) orcustomer(prompt asks for amount + fee). Defaults to your setting in Products → Mobile Collections.callback_urlstringoptionalPublic HTTPS URL. This payment's events are POSTed here as well as to your webhooks. See callback_url.countrystringoptionalISO code, e.g.UG. Must be your account's country.
Response attributes
idstringCashmora reference, e.g.CM-COL-…. Use it with GET.statusstringpending,processing,successful,failed,expiredorunknown.amountintegerWhat the customer is asked for (includes the fee when the customer pays it).feeintegerCashmora fee.net_amountintegerWhat reaches your balance.phone / networkstringPayer number in international format andMTNorAIRTEL.request_idstringYour unique id for this attempt.referencestringYour order number (may repeat across attempts).fee_paid_bystringmerchantorcustomer.customerobjectname, andemail/notewhen a payment page asked for them.checkoutstring|nullHosted checkout id when paid through one.failure_reasonstring|nullWhy it failed, in plain words for your customer.modestringsandboxorlive.created_at / completed_atstringISO 8601, UTC.
#Get a payment
/v1/collections/{id}
One payment by its Cashmora id.
Asking also nudges a waiting payment: if the network has answered, you get the final status straight away.
idstringrequiredTheidfrom the create response, e.g.CM-COL-8F3K2Q9XAB.
Response attributes
idstringCashmora reference, e.g.CM-COL-…. Use it with GET.statusstringpending,processing,successful,failed,expiredorunknown.amountintegerWhat the customer is asked for (includes the fee when the customer pays it).feeintegerCashmora fee.net_amountintegerWhat reaches your balance.phone / networkstringPayer number in international format andMTNorAIRTEL.request_idstringYour unique id for this attempt.referencestringYour order number (may repeat across attempts).fee_paid_bystringmerchantorcustomer.customerobjectname, andemail/notewhen a payment page asked for them.checkoutstring|nullHosted checkout id when paid through one.failure_reasonstring|nullWhy it failed, in plain words for your customer.modestringsandboxorlive.created_at / completed_atstringISO 8601, UTC.
#List payments
/v1/collections
Newest first, with filters.
Find every payment for an order with ?reference=ORDER-1042&status=successful and add up amount to see how much of the order is paid. Find an attempt your server never heard back about with ?request_id=….
limitintegeroptional1–100, default 20.statusstringoptionalpending,processing,successful,failed,expired,unknown.referencestringoptionalYour order number.request_idstringoptionalYour attempt id.starting_afterstringoptionalAnidfrom the previous page, for the next page.
#Payment statuses
| Status | Meaning |
|---|---|
pending | Created; the prompt is being sent. |
processing | Prompt sent; waiting for the customer to approve (about 5 minutes). |
successful | Money received and credited to your balance. Deliver only now. |
failed | Declined, not enough balance, or not registered. See failure_reason. |
expired | Not approved in time. A late approval still becomes successful. |
unknown | The network didn't answer clearly. We keep checking until it does. Never treat it as failed. |
#Create a checkout
/v1/checkouts
A short, embeddable payment page for an exact amount and your reference.
Create the checkout on your server, then show its url inside your page (iframe or modal), in a popup, or redirect to it. The page is compact (it fits a 360–440 px wide box), lets the customer pick how to pay (Mobile Money now, cards soon), and can only be paid once. You are told the result by webhook, by a browser message and, optionally, by a redirect.
amountintegerrequiredUGX 500 – 5,000,000. The customer can't change it.referencestringoptionalYour order number (≤ 100). Shown to the customer and returned on every event.descriptionstringoptionalWhat the payment is for (≤ 255).customerobjectoptionalOptional details about the payer.Show child attributes
customernamestringoptionalSaved as the payer name.emailstringoptionalReceives the receipt.phonestringoptionalPre-fills the number (the customer can change it).
redirect_urlstringoptionalHTTPS. After a successful payment the page goes here with?checkout=…&reference=…&status=paid&payment=….cancel_urlstringoptionalHTTPS. Where "Cancel" goes, withstatus=cancelled.callback_urlstringoptionalHTTPS. This checkout's payment events are POSTed here too.expires_in_minutesintegeroptional5–1440, default 60.fee_paid_bystringoptionalmerchantorcustomer; defaults to your setting.request_idstringoptionalSend the same value on a retry to get the same checkout back.
Response attributes
idstringCheckout id (chk…).statusstringopen,paid,expiredorcancelled.urlstringThe page to show the customer.paymentobject|nullThe successful payment once paid.expires_at / paid_atstringISO 8601, UTC.
#Show it in your page
The checkout page works inside an iframe (no cookies needed), so it can sit in a modal or a section of your own checkout. It sends your window two kinds of messages:
type | When | Extra fields |
|---|---|---|
resize | Whenever its height changes | height (px): set it on the iframe so it never scrolls |
payment.successful | Paid | checkout, reference, merchant_reference, amount, currency |
payment.failed · payment.cancelled · payment.expired | Ended without payment | checkout, merchant_reference |
<iframe id="cashmora" src="CHECKOUT_URL" style="width:100%;max-width:440px;border:0;height:420px"></iframe>
<script>
window.addEventListener('message', function (e) {
if (!e.data || e.data.source !== 'cashmora') return;
var frame = document.getElementById('cashmora');
if (e.data.type === 'resize') { frame.style.height = e.data.height + 'px'; return; }
if (e.data.type === 'payment.successful') {
// Show "thank you" now, but confirm on your server before delivering (see below).
window.location = '/orders/' + e.data.merchant_reference + '/thanks';
}
});
</script>collection.successful webhook (it carries your reference and the checkout id), or after GET /v1/checkouts/{id} says paid.Prefer a redirect? Send redirect_url and cancel_url and open the url as a normal page.
#Get a checkout
/v1/checkouts/{id}
Status of a checkout, with the payment once paid.
open means still waiting (failed attempts can be retried on the same page). paid includes the successful payment under payment.
idstringrequiredThe checkoutid.
#Pre-fill and embed links
Payment links are created in the dashboard (Collect → Payment links). Your system can open one with details already filled in, in a new tab, an iframe or a modal.
| Query parameter | Fills |
|---|---|
amount | The amount, when the link lets the customer choose (still within the link's limits). |
phone | The mobile money number. |
name · email · note | The customer details the link asks for. |
reference | Your order number. Not shown; saved on the payment so webhooks carry it. |
embed=1 | The compact layout (no banner or big header), made for iframes and modals. |
<iframe src="https://app.cashmora.co.ug/pay/YOURLINK?embed=1&amount=25000&phone=0772123456&reference=INV-2041"
style="width:100%;max-width:440px;border:0;height:460px"></iframe>Links send the same resize and payment.* messages (with link instead of checkout). The customer can change pre-filled values, so check the amount in the webhook before delivering. For an amount that can't be changed, use a hosted checkout.
#Send a payout
/v1/payouts
Send money from your balance to an MTN or Airtel number.
Needs a subscription to Transfers, a key created with Allow payouts, and a unique request_id. The amount plus fee is reserved from your balance immediately; a failed payout returns all of it.
unknown, the money stays reserved while we keep checking. Don't send the same payout again with a new request_id: wait for the final status.request_idstringrequiredYour unique id for this payout, e.g. a payroll line id. Retry with the same value.phonestringrequiredRecipient's MTN or Airtel number.amountintegerrequiredWhat the recipient receives (UGX 500 – 5,000,000). The fee is added on top.namestringoptionalRecipient's name (≤ 120).referencestringoptionalYour payroll, invoice or claim number (≤ 100).descriptionstringoptionalReason, shown to the recipient (≤ 255).callback_urlstringoptionalPublic HTTPS URL for this payout's result.countrystringoptionale.g.UG.
Response attributes
idstringCM-PAY-…statusstringpending,processing,successful,failedorunknown.amountintegerWhat the recipient gets.fee / totalintegerFee, and amount + fee taken from your balance.batchstring|nullBulk file reference, for payouts from a bulk upload.
#Get or list payouts
/v1/payouts/{id}
One payout by id. GET /v1/payouts lists them with the same filters as payments.
List with GET /v1/payouts?reference=PAY-001 or ?request_id=….
idstringrequirede.g.CM-PAY-7KQ2M9XB4C.
#Payout statuses
| Status | Meaning |
|---|---|
pending | Created and money reserved. If the network can't be reached we retry for up to ~20 minutes before failing. |
processing | Sent to MTN / Airtel, waiting for confirmation. |
successful | Delivered. |
failed | Not delivered. The full total is back in your available balance. |
unknown | Not confirmed yet. Money stays reserved while we keep checking. |
#Verify a phone number
/v1/phone/verify
Check a number before you charge or pay it.
Tells you whether it's a valid MTN/Airtel number, whether that network is open for payments right now, and the name it is registered to (when known). Free; 60 checks a minute per key. GET /v1/phone/verify?phone=… works too.
name_lookup is found, not_found or unavailable (the name service didn't answer; you can still use the number). Invalid numbers return valid: false with a reason.
phonestringrequiredAny common format.
#Balance
/v1/balance
Your balance in the key's mode.
available can be paid out or withdrawn. pending is payments waiting for customers to approve. reserved is held for payouts in progress.
#List transactions
/v1/transactions
Payments and payouts together, newest first.
Page through with starting_after while meta.has_more is true.
limitintegeroptional1–100, default 20.typestringoptionalcollectionorpayout.statusstringoptionalAny status.referencestringoptionalYour order number.request_idstringoptionalYour attempt id.starting_afterstringoptionalLastidof the previous page.
#Events
Add an HTTPS endpoint under Developers → Webhooks. We POST a signed JSON event when something final happens and retry for about 14 hours (1 min, 5 min, 30 min, 2 h, 12 h) until your endpoint answers with any 2xx. Answer quickly; do slow work afterwards.
| Event | When |
|---|---|
collection.successful | Money received. Fulfil the order. |
collection.failed | Declined or failed. |
collection.expired | Not approved in time. |
payout.successful | Payout delivered. |
payout.failed | Payout failed; money returned to your balance. |
batch.completed | Every payout in a bulk file has a final result. |
POST /your/webhook/url
Content-Type: application/json
Cashmora-Event: collection.successful
Cashmora-Delivery: 01a1f3c2-7d0e-7b4a-9c1e-2f6a8b0d4e11
Cashmora-Signature: t=1792663200,v1=5f2b1c…e9
{
"id": "01a1f3c2-7d0e-7b4a-9c1e-2f6a8b0d4e11",
"created_at": "2026-10-23T09:15:07Z",
"type": "collection.successful",
"data": { "id": "CM-COL-8F3K2Q9XAB", "object": "collection", "status": "successful", "amount": 5000, "reference": "ORDER-1042", "checkout": null }
}- Events can arrive more than once: process each
Cashmora-Delivery(or eventid) only once. - Events can arrive out of order: trust the
statusinside, or fetch the transaction.
#Verify signatures
Every event is signed with your endpoint's secret (Webhooks → Reveal secret). Compute HMAC-SHA256 of <t>.<raw body> with the secret and compare it with v1 in constant time. Reject events older than 5 minutes. Always use the raw request body, not re-encoded JSON.
<?php
$secret = getenv('CASHMORA_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
preg_match('/t=(\d+),v1=([a-f0-9]+)/', $_SERVER['HTTP_CASHMORA_SIGNATURE'] ?? '', $m);
$expected = hash_hmac('sha256', ($m[1] ?? '') . '.' . $body, $secret);
if (!$m || !hash_equals($expected, $m[2]) || abs(time() - (int) $m[1]) > 300) {
http_response_code(400);
exit('Invalid signature');
}
$event = json_decode($body, true);
if ($event['type'] === 'collection.successful') {
// mark order $event['data']['reference'] as paid, once
}
http_response_code(200);// Express: the RAW body is required for this route
const crypto = require('crypto');
app.post('/webhooks/cashmora', express.raw({ type: 'application/json' }), (req, res) => {
const [, t, v1] = (req.get('Cashmora-Signature') || '').match(/t=(\d+),v1=([a-f0-9]+)/) || [];
const expected = crypto.createHmac('sha256', process.env.CASHMORA_WEBHOOK_SECRET).update(`${t}.${req.body}`).digest('hex');
const ok = v1 && v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
&& Math.abs(Date.now() / 1000 - Number(t)) <= 300;
if (!ok) return res.status(400).send('Invalid signature');
const event = JSON.parse(req.body);
res.sendStatus(200);
});import hmac, hashlib, os, re, time
from flask import request, abort
@app.post('/webhooks/cashmora')
def cashmora_webhook():
body = request.get_data()
m = re.match(r't=(\d+),v1=([a-f0-9]+)', request.headers.get('Cashmora-Signature', ''))
if not m:
abort(400)
t, v1 = m.groups()
expected = hmac.new(os.environ['CASHMORA_WEBHOOK_SECRET'].encode(), f'{t}.'.encode() + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1) or abs(time.time() - int(t)) > 300:
abort(400)
return '', 200#callback_url per request
Send callback_url on a collection, payout or checkout and that transaction's final events are POSTed there too: same body, retries and Cashmora-Signature header as webhooks. They are signed with your callback_url signing secret (Webhooks → callback_url signing secret); test and live have different secrets. The URL must be public HTTPS; private and local addresses are refused. If every payment goes to the same place, use a webhook endpoint instead.
#Request IDs and retries
Every create request carries two different identifiers:
| Field | Identifies | Unique? | Example |
|---|---|---|---|
request_id (required) | One payment attempt | Yes, per business and mode | 3f8e4c1a-6b2d-… |
reference (optional) | Your order or invoice | No, can repeat | OR-0097878 |
- Generate the
request_idwhen you create the attempt, save it before calling us, and reuse it only to retry that attempt. - Same
request_id+ same phone and amount → the original transaction (HTTP 200, headerIdempotent-Replayed: true). Nobody is charged twice. - Same
request_id+ different details →409 duplicate_request_id. - On a timeout or
5xx, retry with the samerequest_id, or look it up withGET /v1/collections?request_id=….
Part payments. One order can have several payments, each with its own request_id and the same reference:
POST /v1/collections {"request_id":"9b1d…","reference":"OR-0097878","amount":140000} → successful
POST /v1/collections {"request_id":"4c7a…","reference":"OR-0097878","amount":60000} → successful
GET /v1/collections?reference=OR-0097878&status=successful → 200,000 in total#Fees: who pays
Each successful collection pays the Mobile Collections fee shown in your dashboard. You choose who pays it, per business (Products → Mobile Collections) or per request (fee_paid_by):
fee_paid_by | Customer is prompted for | You receive |
|---|---|---|
merchant | amount | amount − fee |
customer | amount + fee | amount |
Payout fees are always added on top of the payout and taken from your balance (total).
#Errors
| HTTP | code | Meaning and what to do |
|---|---|---|
| 400 | invalid_json | Body isn't JSON, or Content-Type is missing. |
| 400 | request_id_mismatch | request_id and the Idempotency-Key header differ. |
| 401 | unauthenticated | Missing, invalid or revoked key. |
| 403 | product_not_active | Subscribe to Mobile Collections and accept its latest terms. |
| 403 | not_subscribed | Subscribe to Transfers to send payouts. |
| 403 | payouts_not_allowed | This key wasn't created with "Allow payouts". |
| 403 | not_live | Live key, but live payments aren't enabled for your business yet. |
| 403 | ip_not_allowed | The key is locked to other IP addresses. |
| 403 | business_suspended | Contact Cashmora support. |
| 404 | not_found | Nothing with that id for this key's mode. |
| 409 | duplicate_request_id | That request_id belongs to another payment. Use a new one for a new attempt. |
| 422 | validation_failed | See error.fields for each problem. |
| 422 | insufficient_balance | Available balance is less than amount + fee. |
| 422 | limit_exceeded | Today's payout limit would be passed. |
| 429 | rate_limited | Slow down; wait Retry-After seconds. |
| 503 | network_unavailable | That network is under maintenance for live money. Show the message, or use another network. |
| 500 | server_error | Our fault. Retry with the same request_id. |
#Limits
| Rate limit | 120 requests a minute per test key, 600 per live key |
| Phone checks | 60 a minute per key |
| Collection amount | UGX 500 – 5,000,000 per payment |
| Payout amount | UGX 500 – 5,000,000 per payout |
| Daily payouts (live) | Depends on your verification level |
| Approval window | About 5 minutes for the customer to approve |
| Checkout lifetime | 5 minutes – 24 hours (default 60 minutes) |
#Go-live checklist
- Business verified and live payments enabled by Cashmora.
- Two-step verification on for everyone who can create keys or send money.
- Live key in your server's environment settings, never in code. A separate key with "Allow payouts" only where needed, ideally IP-locked.
- Live webhook endpoint (HTTPS) added, signatures verified, events de-duplicated by
Cashmora-Delivery. - Goods released only on
successful;unknowntreated as "still waiting". - A new
request_idfor every attempt, saved before the call; retries reuse it. Orders matched byreference. network_unavailablehandled by showing its message (e.g. "Please use an MTN number").
#For AI assistants
These docs are available in machine-friendly formats, so coding assistants and crawlers can integrate Cashmora correctly:
| File | What it is |
|---|---|
| /llms.txt | Short index with links (the llms.txt convention) |
| /llms-full.txt | Every page of these docs as one Markdown file |
| /openapi.json | OpenAPI 3.1 description of the API |
Key rules for generated code: keep the secret key on the server; send a fresh request_id per attempt and reuse it on retries; deliver only on successful (or a verified collection.successful webhook); treat unknown as pending.