Cashmora Cashmora Docs
API v1

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.

Base URLhttps://api.cashmora.co.ug/v1
Examples in
Getting started

#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 URLhttps://api.cashmora.co.ug/v1
FormatJSON in, JSON out. Send Content-Type: application/json.
AuthenticationAuthorization: Bearer <secret key> on every request.
AmountsWhole numbers in UGX (no decimals): 5000 is UGX 5,000.
Phone numbersAny common format: 0772123456, 256772123456, +256 772 123 456.
TimesISO 8601 in UTC, e.g. 2026-10-23T09:15:00Z.
SupportQuote 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":{…}}}.

Tip Building with an AI coding assistant? Point it at https://docs.cashmora.co.ug/llms-full.txt (all of these docs as Markdown) or https://docs.cashmora.co.ug/openapi.json.
Getting started

#Quickstart

Take a test payment in five minutes. No real money moves in sandbox.

  1. Create a Cashmora account and verify your email.
  2. Subscribe to Mobile Collections under Products (free in sandbox).
  3. Create a test key under Developers → API keys. It starts with cm_test_sk_.
  4. Request a payment from the test number 0770000001. It succeeds a few seconds later.
  5. Add a webhook endpoint under Developers → Webhooks so your system hears about results without polling.
bash
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.

Getting started

#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.

KeyPrefixUse
Testcm_test_sk_Sandbox: simulator and test numbers. No real money.
Livecm_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 (sandbox or live). Nothing else changes.
bash
curl https://api.cashmora.co.ug/v1/balance -H "Authorization: Bearer cm_test_sk_YOUR_KEY"
Getting started

#Check your key

GET /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.

Getting started

#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.
Getting started

#Test numbers

With a test key, these numbers force an outcome. Any other valid MTN or Airtel number succeeds after a few seconds.

NumberCollectionsPayouts
0770000001 · 0700000001Approved after a few secondsDelivered
0770000002 · 0700000002Customer declines → failedNot registered → failed, money returned
0770000003 · 0700000003Never answered → expiredSlow network → delivered after about a minute
0770000004 · 0700000004Times out → unknown → successfulTimes 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.

Collections

#Request a payment

POST /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.

Important Only deliver goods when the status is successful. Treat unknown as "still waiting", never as failed.
Body parameters
  • request_idstringrequired
    Your 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 the Idempotency-Key header.
  • phonestringrequired
    Customer's MTN or Airtel number.
  • amountintegerrequired
    UGX 500 – 5,000,000.
  • referencestringoptional
    Your order or invoice number (≤ 100). Can repeat across attempts and part payments.
  • descriptionstringoptional
    What the payment is for (≤ 255).
  • payer_namestringoptional
    Customer's name, for your records (≤ 120).
  • fee_paid_bystringoptional
    merchant (fee taken from what you receive) or customer (prompt asks for amount + fee). Defaults to your setting in Products → Mobile Collections.
  • callback_urlstringoptional
    Public HTTPS URL. This payment's events are POSTed here as well as to your webhooks. See callback_url.
  • countrystringoptional
    ISO code, e.g. UG. Must be your account's country.
Response attributes
  • idstring
    Cashmora reference, e.g. CM-COL-…. Use it with GET.
  • statusstring
    pending, processing, successful, failed, expired or unknown.
  • amountinteger
    What the customer is asked for (includes the fee when the customer pays it).
  • feeinteger
    Cashmora fee.
  • net_amountinteger
    What reaches your balance.
  • phone / networkstring
    Payer number in international format and MTN or AIRTEL.
  • request_idstring
    Your unique id for this attempt.
  • referencestring
    Your order number (may repeat across attempts).
  • fee_paid_bystring
    merchant or customer.
  • customerobject
    name, and email / note when a payment page asked for them.
  • checkoutstring|null
    Hosted checkout id when paid through one.
  • failure_reasonstring|null
    Why it failed, in plain words for your customer.
  • modestring
    sandbox or live.
  • created_at / completed_atstring
    ISO 8601, UTC.
Collections

#Get a payment

GET /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.

Path parameters
  • idstringrequired
    The id from the create response, e.g. CM-COL-8F3K2Q9XAB.
Response attributes
  • idstring
    Cashmora reference, e.g. CM-COL-…. Use it with GET.
  • statusstring
    pending, processing, successful, failed, expired or unknown.
  • amountinteger
    What the customer is asked for (includes the fee when the customer pays it).
  • feeinteger
    Cashmora fee.
  • net_amountinteger
    What reaches your balance.
  • phone / networkstring
    Payer number in international format and MTN or AIRTEL.
  • request_idstring
    Your unique id for this attempt.
  • referencestring
    Your order number (may repeat across attempts).
  • fee_paid_bystring
    merchant or customer.
  • customerobject
    name, and email / note when a payment page asked for them.
  • checkoutstring|null
    Hosted checkout id when paid through one.
  • failure_reasonstring|null
    Why it failed, in plain words for your customer.
  • modestring
    sandbox or live.
  • created_at / completed_atstring
    ISO 8601, UTC.
Collections

#List payments

GET /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=….

Query parameters
  • limitintegeroptional
    1–100, default 20.
  • statusstringoptional
    pending, processing, successful, failed, expired, unknown.
  • referencestringoptional
    Your order number.
  • request_idstringoptional
    Your attempt id.
  • starting_afterstringoptional
    An id from the previous page, for the next page.
Collections

#Payment statuses

StatusMeaning
pendingCreated; the prompt is being sent.
processingPrompt sent; waiting for the customer to approve (about 5 minutes).
successfulMoney received and credited to your balance. Deliver only now.
failedDeclined, not enough balance, or not registered. See failure_reason.
expiredNot approved in time. A late approval still becomes successful.
unknownThe network didn't answer clearly. We keep checking until it does. Never treat it as failed.
Hosted checkout

#Create a checkout

POST /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.

Body parameters
  • amountintegerrequired
    UGX 500 – 5,000,000. The customer can't change it.
  • referencestringoptional
    Your order number (≤ 100). Shown to the customer and returned on every event.
  • descriptionstringoptional
    What the payment is for (≤ 255).
  • customerobjectoptional
    Optional details about the payer.
    Show child attributes
    customer
    • namestringoptional
      Saved as the payer name.
    • emailstringoptional
      Receives the receipt.
    • phonestringoptional
      Pre-fills the number (the customer can change it).
  • redirect_urlstringoptional
    HTTPS. After a successful payment the page goes here with ?checkout=…&reference=…&status=paid&payment=….
  • cancel_urlstringoptional
    HTTPS. Where "Cancel" goes, with status=cancelled.
  • callback_urlstringoptional
    HTTPS. This checkout's payment events are POSTed here too.
  • expires_in_minutesintegeroptional
    5–1440, default 60.
  • fee_paid_bystringoptional
    merchant or customer; defaults to your setting.
  • request_idstringoptional
    Send the same value on a retry to get the same checkout back.
Response attributes
  • idstring
    Checkout id (chk…).
  • statusstring
    open, paid, expired or cancelled.
  • urlstring
    The page to show the customer.
  • paymentobject|null
    The successful payment once paid.
  • expires_at / paid_atstring
    ISO 8601, UTC.
Hosted checkout

#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:

typeWhenExtra fields
resizeWhenever its height changesheight (px): set it on the iframe so it never scrolls
payment.successfulPaidcheckout, reference, merchant_reference, amount, currency
payment.failed · payment.cancelled · payment.expiredEnded without paymentcheckout, merchant_reference
html
<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>
Warning Any page can post a message to your window, so never deliver an order because of a browser message alone. Deliver when your server receives the 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.

Hosted checkout

#Get a checkout

GET /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.

Path parameters
  • idstringrequired
    The checkout id.
Payouts

#Send a payout

POST /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.

Warning If a payout is 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.
Body parameters
  • request_idstringrequired
    Your unique id for this payout, e.g. a payroll line id. Retry with the same value.
  • phonestringrequired
    Recipient's MTN or Airtel number.
  • amountintegerrequired
    What the recipient receives (UGX 500 – 5,000,000). The fee is added on top.
  • namestringoptional
    Recipient's name (≤ 120).
  • referencestringoptional
    Your payroll, invoice or claim number (≤ 100).
  • descriptionstringoptional
    Reason, shown to the recipient (≤ 255).
  • callback_urlstringoptional
    Public HTTPS URL for this payout's result.
  • countrystringoptional
    e.g. UG.
Response attributes
  • idstring
    CM-PAY-…
  • statusstring
    pending, processing, successful, failed or unknown.
  • amountinteger
    What the recipient gets.
  • fee / totalinteger
    Fee, and amount + fee taken from your balance.
  • batchstring|null
    Bulk file reference, for payouts from a bulk upload.
Payouts

#Get or list payouts

GET /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=….

Path parameters
  • idstringrequired
    e.g. CM-PAY-7KQ2M9XB4C.
Payouts

#Payout statuses

StatusMeaning
pendingCreated and money reserved. If the network can't be reached we retry for up to ~20 minutes before failing.
processingSent to MTN / Airtel, waiting for confirmation.
successfulDelivered.
failedNot delivered. The full total is back in your available balance.
unknownNot confirmed yet. Money stays reserved while we keep checking.
Account & tools

#Verify a phone number

POST /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.

Body parameters
  • phonestringrequired
    Any common format.
Account & tools

#Balance

GET /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.

Account & tools

#List transactions

GET /v1/transactions

Payments and payouts together, newest first.

Page through with starting_after while meta.has_more is true.

Query parameters
  • limitintegeroptional
    1–100, default 20.
  • typestringoptional
    collection or payout.
  • statusstringoptional
    Any status.
  • referencestringoptional
    Your order number.
  • request_idstringoptional
    Your attempt id.
  • starting_afterstringoptional
    Last id of the previous page.
Webhooks

#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.

EventWhen
collection.successfulMoney received. Fulfil the order.
collection.failedDeclined or failed.
collection.expiredNot approved in time.
payout.successfulPayout delivered.
payout.failedPayout failed; money returned to your balance.
batch.completedEvery payout in a bulk file has a final result.
http
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 event id) only once.
  • Events can arrive out of order: trust the status inside, or fetch the transaction.
Webhooks

#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
<?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);
javascript
// 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);
});
python
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
Webhooks

#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.

Guides

#Request IDs and retries

Every create request carries two different identifiers:

FieldIdentifiesUnique?Example
request_id (required)One payment attemptYes, per business and mode3f8e4c1a-6b2d-…
reference (optional)Your order or invoiceNo, can repeatOR-0097878
  • Generate the request_id when 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, header Idempotent-Replayed: true). Nobody is charged twice.
  • Same request_id + different details → 409 duplicate_request_id.
  • On a timeout or 5xx, retry with the same request_id, or look it up with GET /v1/collections?request_id=….

Part payments. One order can have several payments, each with its own request_id and the same reference:

text
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
Guides

#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_byCustomer is prompted forYou receive
merchantamountamount − fee
customeramount + feeamount

Payout fees are always added on top of the payout and taken from your balance (total).

Guides

#Errors

HTTPcodeMeaning and what to do
400invalid_jsonBody isn't JSON, or Content-Type is missing.
400request_id_mismatchrequest_id and the Idempotency-Key header differ.
401unauthenticatedMissing, invalid or revoked key.
403product_not_activeSubscribe to Mobile Collections and accept its latest terms.
403not_subscribedSubscribe to Transfers to send payouts.
403payouts_not_allowedThis key wasn't created with "Allow payouts".
403not_liveLive key, but live payments aren't enabled for your business yet.
403ip_not_allowedThe key is locked to other IP addresses.
403business_suspendedContact Cashmora support.
404not_foundNothing with that id for this key's mode.
409duplicate_request_idThat request_id belongs to another payment. Use a new one for a new attempt.
422validation_failedSee error.fields for each problem.
422insufficient_balanceAvailable balance is less than amount + fee.
422limit_exceededToday's payout limit would be passed.
429rate_limitedSlow down; wait Retry-After seconds.
503network_unavailableThat network is under maintenance for live money. Show the message, or use another network.
500server_errorOur fault. Retry with the same request_id.
Guides

#Limits

Rate limit120 requests a minute per test key, 600 per live key
Phone checks60 a minute per key
Collection amountUGX 500 – 5,000,000 per payment
Payout amountUGX 500 – 5,000,000 per payout
Daily payouts (live)Depends on your verification level
Approval windowAbout 5 minutes for the customer to approve
Checkout lifetime5 minutes – 24 hours (default 60 minutes)
Guides

#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; unknown treated as "still waiting".
  • A new request_id for every attempt, saved before the call; retries reuse it. Orders matched by reference.
  • network_unavailable handled by showing its message (e.g. "Please use an MTN number").
Guides

#For AI assistants

These docs are available in machine-friendly formats, so coding assistants and crawlers can integrate Cashmora correctly:

FileWhat it is
/llms.txtShort index with links (the llms.txt convention)
/llms-full.txtEvery page of these docs as one Markdown file
/openapi.jsonOpenAPI 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.