# Cashmora API documentation > Mobile money collections, payouts and hosted checkout for Uganda (MTN, Airtel). Base URL: https://api.cashmora.co.ug/v1 ## 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 URL | `https://api.cashmora.co.ug/v1` | | Format | JSON in, JSON out. Send `Content-Type: application/json`. | | Authentication | `Authorization: Bearer ` 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":{…}}}`. > Tip: Building with an AI coding assistant? Point it at [https://docs.cashmora.co.ug/llms-full.txt](https://docs.cashmora.co.ug/llms-full.txt) (all of these docs as Markdown) or [https://docs.cashmora.co.ug/openapi.json](https://docs.cashmora.co.ug/openapi.json). ### Quickstart Take a test payment in five minutes. No real money moves in sandbox. 1. [Create a Cashmora account](https://app.cashmora.co.ug/register) 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. ### 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](#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 (`sandbox` or `live`). Nothing else changes. ```bash curl https://api.cashmora.co.ug/v1/balance -H "Authorization: Bearer cm_test_sk_YOUR_KEY" ``` ### Check your key — `GET /v1/ping` 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`. **Example request** ```bash curl https://api.cashmora.co.ug/v1/ping ``` **Response 200 OK** ```json { "status": "ok", "data": { "service": "cashmora-api", "version": "v1", "time": "2026-10-28T09:00:00Z", "key": "valid", "mode": "sandbox" } } ``` ### 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](#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. ## 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](#webhooks) or with [GET](#get-collection). 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 (JSON)** | Name | Type | Required | Description | |---|---|---|---| | `request_id` | string | yes | 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. | | `phone` | string | yes | Customer's MTN or Airtel number. | | `amount` | integer | yes | UGX 500 – 5,000,000. | | `reference` | string | no | Your order or invoice number (≤ 100). **Can repeat** across attempts and part payments. | | `description` | string | no | What the payment is for (≤ 255). | | `payer_name` | string | no | Customer's name, for your records (≤ 120). | | `fee_paid_by` | string | no | `merchant` (fee taken from what you receive) or `customer` (prompt asks for amount + fee). Defaults to your setting in Products → Mobile Collections. | | `callback_url` | string | no | Public HTTPS URL. This payment's events are POSTed here as well as to your webhooks. See [callback_url](#callback-url). | | `country` | string | no | ISO code, e.g. `UG`. Must be your account's country. | **Example request** ```bash curl -X POST https://api.cashmora.co.ug/v1/collections \ -H "Authorization: Bearer $CASHMORA_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"request_id":"3f8e4c1a-6b2d-4f7e-9a10-5c3b2e1d0f9a","phone":"0770000001","amount":5000,"reference":"ORDER-1042","description":"Order #1042","payer_name":"Jane Namuli"}' ``` **Response 202 Accepted** ```json { "status": "success", "data": { "id": "CM-COL-8F3K2Q9XAB", "object": "collection", "status": "pending", "amount": 5000, "fee": 100, "net_amount": 4900, "currency": "UGX", "phone": "+256770000001", "network": "MTN", "request_id": "3f8e4c1a-6b2d-4f7e-9a10-5c3b2e1d0f9a", "callback_url": null, "reference": "ORDER-1042", "description": "Order #1042", "channel": "api", "fee_paid_by": "merchant", "customer": { "name": "Jane Namuli", "email": null, "note": null }, "checkout": null, "provider_reference": null, "failure_reason": null, "mode": "sandbox", "created_at": "2026-10-23T09:15:00Z", "completed_at": null } } ``` **Response 422 Validation failed** ```json { "status": "error", "error": { "code": "validation_failed", "message": "Some details need your attention.", "fields": { "phone": "Only MTN and Airtel numbers can pay with mobile money.", "amount": "Amount must be between UGX 500 and UGX 5,000,000." } } } ``` **Response 401 Bad key** ```json { "status": "error", "error": { "code": "unauthenticated", "message": "Send a valid API key: Authorization: Bearer cm_test_sk_..." } } ``` ### 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** | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | The `id` from the create response, e.g. `CM-COL-8F3K2Q9XAB`. | **Example request** ```bash curl https://api.cashmora.co.ug/v1/collections/CM-COL-8F3K2Q9XAB \ -H "Authorization: Bearer $CASHMORA_SECRET_KEY" ``` **Response 200 OK** ```json { "status": "success", "data": { "id": "CM-COL-8F3K2Q9XAB", "object": "collection", "status": "successful", "amount": 5000, "fee": 100, "net_amount": 4900, "currency": "UGX", "phone": "+256770000001", "network": "MTN", "request_id": "3f8e4c1a-6b2d-4f7e-9a10-5c3b2e1d0f9a", "callback_url": null, "reference": "ORDER-1042", "description": "Order #1042", "channel": "api", "fee_paid_by": "merchant", "customer": { "name": "Jane Namuli", "email": null, "note": null }, "checkout": null, "provider_reference": null, "failure_reason": null, "mode": "sandbox", "created_at": "2026-10-23T09:15:00Z", "completed_at": "2026-10-23T09:15:07Z" } } ``` **Response 404 Not found** ```json { "status": "error", "error": { "code": "not_found", "message": "No collection with that id." } } ``` ### 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** | Name | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | 1–100, default 20. | | `status` | string | no | `pending`, `processing`, `successful`, `failed`, `expired`, `unknown`. | | `reference` | string | no | Your order number. | | `request_id` | string | no | Your attempt id. | | `starting_after` | string | no | An `id` from the previous page, for the next page. | **Example request** ```bash curl "https://api.cashmora.co.ug/v1/collections?reference=ORDER-1042&status=successful" \ -H "Authorization: Bearer $CASHMORA_SECRET_KEY" ``` **Response 200 OK** ```json { "status": "success", "data": [ { "id": "CM-COL-8F3K2Q9XAB", "status": "successful", "amount": 140000, "reference": "OR-0097878", "...": "..." } ], "meta": { "has_more": false } } ``` ### 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. | ## 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](#webhooks), by a [browser message](#embed-checkout) and, optionally, by a redirect. **Body (JSON)** | Name | Type | Required | Description | |---|---|---|---| | `amount` | integer | yes | UGX 500 – 5,000,000. The customer can't change it. | | `reference` | string | no | Your order number (≤ 100). Shown to the customer and returned on every event. | | `description` | string | no | What the payment is for (≤ 255). | | `customer` | object | no | Optional details about the payer. | | `customer.name` | string | no | Saved as the payer name. | | `customer.email` | string | no | Receives the receipt. | | `customer.phone` | string | no | Pre-fills the number (the customer can change it). | | `redirect_url` | string | no | HTTPS. After a successful payment the page goes here with `?checkout=…&reference=…&status=paid&payment=…`. | | `cancel_url` | string | no | HTTPS. Where "Cancel" goes, with `status=cancelled`. | | `callback_url` | string | no | HTTPS. This checkout's payment events are POSTed here too. | | `expires_in_minutes` | integer | no | 5–1440, default 60. | | `fee_paid_by` | string | no | `merchant` or `customer`; defaults to your setting. | | `request_id` | string | no | Send the same value on a retry to get the same checkout back. | **Example request** ```bash curl -X POST https://api.cashmora.co.ug/v1/checkouts \ -H "Authorization: Bearer $CASHMORA_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"amount":45000,"reference":"INV-2041","description":"Order #2041","customer":{"name":"Jane Namuli","email":"jane@example.com"},"redirect_url":"https://yourshop.com/orders/2041/thanks"}' ``` **Response 201 Created** ```json { "status": "success", "data": { "id": "chkQ3x9sT2mVb8LwP4nR6yA1", "object": "checkout", "status": "open", "amount": 45000, "currency": "UGX", "reference": "INV-2041", "description": "Order #2041", "url": "https://app.cashmora.co.ug/checkout/chkQ3x9sT2mVb8LwP4nR6yA1", "redirect_url": "https://yourshop.com/orders/2041/thanks", "cancel_url": null, "expires_at": "2026-10-28T10:15:00Z", "paid_at": null, "payment": null, "mode": "sandbox", "created_at": "2026-10-28T09:15:00Z" } } ``` **Response 422 Validation failed** ```json { "status": "error", "error": { "code": "validation_failed", "message": "Some details need your attention.", "fields": { "phone": "Only MTN and Airtel numbers can pay with mobile money.", "amount": "Amount must be between UGX 500 and UGX 5,000,000." } } } ``` ### 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` | ```html ``` > 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](#webhooks) (it carries your `reference` and the `checkout` id), or after [GET /v1/checkouts/{id}](#get-checkout) says `paid`. Prefer a redirect? Send `redirect_url` and `cancel_url` and open the `url` as a normal page. ### 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** | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | The checkout `id`. | **Example request** ```bash curl https://api.cashmora.co.ug/v1/checkouts/chkQ3x9sT2mVb8LwP4nR6yA1 \ -H "Authorization: Bearer $CASHMORA_SECRET_KEY" ``` **Response 200 OK** ```json { "status": "success", "data": { "id": "chkQ3x9sT2mVb8LwP4nR6yA1", "object": "checkout", "status": "paid", "amount": 45000, "currency": "UGX", "reference": "INV-2041", "description": "Order #2041", "url": "https://app.cashmora.co.ug/checkout/chkQ3x9sT2mVb8LwP4nR6yA1", "redirect_url": "https://yourshop.com/orders/2041/thanks", "cancel_url": null, "expires_at": "2026-10-28T10:15:00Z", "paid_at": "2026-10-28T09:17:41Z", "payment": { "id": "CM-COL-2H7P4K9QZD", "status": "successful", "amount": 45000, "reference": "INV-2041", "checkout": "chkQ3x9sT2mVb8LwP4nR6yA1", "...": "..." }, "mode": "sandbox", "created_at": "2026-10-28T09:15:00Z" } } ``` ## Payment links ### 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. | ```html ``` Links send the same `resize` and `payment.*` [messages](#embed-checkout) (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](#create-checkout). ## 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 (JSON)** | Name | Type | Required | Description | |---|---|---|---| | `request_id` | string | yes | Your unique id for **this payout**, e.g. a payroll line id. Retry with the same value. | | `phone` | string | yes | Recipient's MTN or Airtel number. | | `amount` | integer | yes | What the recipient receives (UGX 500 – 5,000,000). The fee is added on top. | | `name` | string | no | Recipient's name (≤ 120). | | `reference` | string | no | Your payroll, invoice or claim number (≤ 100). | | `description` | string | no | Reason, shown to the recipient (≤ 255). | | `callback_url` | string | no | Public HTTPS URL for this payout's result. | | `country` | string | no | e.g. `UG`. | **Example request** ```bash curl -X POST https://api.cashmora.co.ug/v1/payouts \ -H "Authorization: Bearer $CASHMORA_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"request_id":"salary-2026-10-emp-0042","phone":"0770000001","amount":50000,"name":"Jane Namuli","reference":"PAY-001","description":"October salary"}' ``` **Response 202 Accepted** ```json { "status": "success", "data": { "id": "CM-PAY-7KQ2M9XB4C", "object": "payout", "status": "pending", "amount": 50000, "fee": 1000, "total": 51000, "currency": "UGX", "phone": "+256770000001", "network": "MTN", "name": "Jane Namuli", "request_id": "salary-2026-10-emp-0042", "reference": "PAY-001", "description": "October salary", "channel": "api", "batch": null, "provider_reference": null, "failure_reason": null, "mode": "sandbox", "created_at": "2026-10-23T09:20:00Z", "completed_at": null } } ``` **Response 422 Not enough balance** ```json { "status": "error", "error": { "code": "insufficient_balance", "message": "Your available balance is UGX 20,000. This payout needs UGX 51,000." } } ``` ### 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** | Name | Type | Required | Description | |---|---|---|---| | `id` | string | yes | e.g. `CM-PAY-7KQ2M9XB4C`. | **Example request** ```bash curl https://api.cashmora.co.ug/v1/payouts/CM-PAY-7KQ2M9XB4C \ -H "Authorization: Bearer $CASHMORA_SECRET_KEY" ``` **Response 200 OK** ```json { "status": "success", "data": { "id": "CM-PAY-7KQ2M9XB4C", "object": "payout", "status": "successful", "amount": 50000, "fee": 1000, "total": 51000, "currency": "UGX", "phone": "+256770000001", "network": "MTN", "name": "Jane Namuli", "request_id": "salary-2026-10-emp-0042", "reference": "PAY-001", "description": "October salary", "channel": "api", "batch": null, "provider_reference": null, "failure_reason": null, "mode": "sandbox", "created_at": "2026-10-23T09:20:00Z", "completed_at": null } } ``` ### 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. | ## 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 (JSON)** | Name | Type | Required | Description | |---|---|---|---| | `phone` | string | yes | Any common format. | **Example request** ```bash curl -X POST https://api.cashmora.co.ug/v1/phone/verify \ -H "Authorization: Bearer $CASHMORA_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{"phone":"0772123456"}' ``` **Response 200 OK** ```json { "status": "success", "data": { "phone": "+256772123456", "valid": true, "network": "MTN", "can_collect": true, "can_pay_out": true, "notice": null, "registered_name": "Jane Namuli", "name_verified": true, "name_lookup": "found", "mode": "sandbox" } } ``` **Response 200 Not a mobile money number** ```json { "status": "success", "data": { "phone": "+256312000000", "valid": false, "network": null, "reason": "Only MTN and Airtel Mobile Money numbers are supported." } } ``` ### 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. **Example request** ```bash curl https://api.cashmora.co.ug/v1/balance \ -H "Authorization: Bearer $CASHMORA_SECRET_KEY" ``` **Response 200 OK** ```json { "status": "success", "data": { "currency": "UGX", "available": 245000, "pending": 15000, "reserved": 51000, "mode": "sandbox" } } ``` ### List transactions — `GET /v1/transactions` Payments and payouts together, newest first. Page through with `starting_after` while `meta.has_more` is true. **Query parameters** | Name | Type | Required | Description | |---|---|---|---| | `limit` | integer | no | 1–100, default 20. | | `type` | string | no | `collection` or `payout`. | | `status` | string | no | Any status. | | `reference` | string | no | Your order number. | | `request_id` | string | no | Your attempt id. | | `starting_after` | string | no | Last `id` of the previous page. | **Example request** ```bash curl "https://api.cashmora.co.ug/v1/transactions?type=payout&limit=50" \ -H "Authorization: Bearer $CASHMORA_SECRET_KEY" ``` **Response 200 OK** ```json { "status": "success", "data": [ { "id": "CM-PAY-7KQ2M9XB4C", "object": "payout", "status": "successful", "...": "..." } ], "meta": { "has_more": true } } ``` ## 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. | 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. | ```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. ### Verify signatures Every event is signed with your endpoint's secret (Webhooks → Reveal secret). Compute HMAC-SHA256 of `.` 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 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 ``` ### 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: | 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_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 ``` ### 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`; `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"). ### 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](https://docs.cashmora.co.ug/llms.txt) | Short index with links (the llms.txt convention) | | [/llms-full.txt](https://docs.cashmora.co.ug/llms-full.txt) | Every page of these docs as one Markdown file | | [/openapi.json](https://docs.cashmora.co.ug/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.