TiinyPay API
Collect mobile money, send payouts and give each of your own customers a wallet with its own balance. Every request is made from your server with an API key.
Base URL:
Requests and responses are JSON. Successful responses look like {"success": true, "data": {...}}. Supported networks today: MTN and Airtel Uganda, in UGX.
How wallets fit together
Your business has one or more main wallets. Under each main wallet you can create sub-wallets, one per customer on your platform (a shop on your POS, a member of your app). Each wallet has its own balance and transaction history. Money moves into a wallet through collections or transfers, and out through payouts or transfers.
Authentication
Create API keys in the dashboard under Developers. Send the key in the Authorization header. Keys are shown once; store them in your server's environment, never in a browser or mobile app.
Authorization: Bearer tp_live_3f9a1c0b2e7d_9b1c...
Every key belongs to one business and is limited in three ways:
| Control | What it does |
|---|---|
| Permissions | wallets:read, wallets:write, deposits:write, payouts:write, transfers:write, transactions:read, msisdn:validate. A call without the needed permission returns 403 INSUFFICIENT_SCOPE. |
| Wallets | Optionally limited to chosen wallets. A main wallet in the list includes all its sub-wallets. Other wallets return 404 WALLET_NOT_FOUND, as if they did not exist. |
| Server IPs | Optional allowlist of IPs or CIDR ranges. Required for live keys with payouts:write. |
Sandbox and live
Every account has two separate environments. Test keys (tp_test_…) only see test wallets and never touch real money. Live keys (tp_live_…) only see live wallets and are available after your business is verified. Wallet, transaction and reference namespaces are fully separate, so the same reference can exist in both.
Every object includes livemode (true or false), including webhook payloads.
Test phone numbers
| Number | What happens |
|---|---|
+256700000002 | The customer declines (collections) or the payout fails. Funds held for a payout are returned. |
+256700000003 | Stays processing for about 60 seconds, then succeeds. Use it to test polling and webhooks. |
+256700000004 | Not a registered mobile money number: rejected immediately, and fails verification. |
| Any other valid number | Succeeds immediately with realistic fees (3% on collections, the band fee on payouts). |
The 5-requests-per-number-per-10-minutes rule applies in the sandbox too, so you can test your handling of MSISDN_RATE_LIMITED. Test traffic never counts against live limits.
Going live
Verify your business under Go live in the dashboard. Live payouts are limited per rolling 7 days according to your account type; the remaining allowance is returned by Quote fees as weekly_limit_remaining.
Amounts and fees
Send amounts as whole UGX integers: 5000, not "5000" or 5000.5. Mobile money transactions must be between 500 and 5,000,000 UGX. Responses return money as decimal strings, such as "1136.88", because network fees can include fractions.
Collection fees
The network charges a percentage on every collection (3% today). Each wallet decides who pays it with deposit_fee_bearer, and you can override it per request with fee_bearer:
| Value | Customer is asked for | Wallet receives |
|---|---|---|
wallet | The amount you send, e.g. 10,000 | Amount minus the fee, e.g. 9,700 |
customer | Amount plus the fee, e.g. 10,310 | About the amount, e.g. 10,000 |
The final credit always uses the exact fee the network reports.
Payout fees
Payout fees are a flat amount per band. Before sending, TiinyPay holds the payout amount plus an estimated fee with a safety buffer. If the wallet can't cover that, the payout is refused with 402 INSUFFICIENT_FUNDS and nothing is sent. When the payout completes, any unused part of the estimate goes back to the wallet. Use Quote fees to show the numbers before you send.
Errors
{
"success": false,
"error": {
"code": "INSUFFICIENT_FUNDS",
"message": "The wallet cannot cover the payout plus the estimated mobile money fee.",
"details": { "available": "3000.00", "required": "5800.00", "amount": "5000.00", "estimated_fee": "800.00", "platform_fee": "0.00" }
},
"request_id": "req_6f1d0c2b9a8e7d6c5b4a"
}
| HTTP | Code | Meaning |
|---|---|---|
| 401 | UNAUTHENTICATED, KEY_REVOKED | Missing, wrong, revoked or expired key. |
| 402 | INSUFFICIENT_FUNDS | The wallet balance can't cover the amount and fees. |
| 403 | INSUFFICIENT_SCOPE, IP_NOT_ALLOWED, LIVE_NOT_ENABLED | The key or business isn't allowed to do this. |
| 403 | WEEKLY_LIMIT_REACHED | The payout would exceed your 7-day live payout limit. details.remaining shows what's left. |
| 404 | WALLET_NOT_FOUND, NOT_FOUND | Doesn't exist, or this key can't see it. |
| 409 | DUPLICATE_REFERENCE, WALLET_NOT_ACTIVE | Reference already used (the existing transaction_id is in details), or wallet is frozen. |
| 422 | VALIDATION_ERROR, MODE_MISMATCH | A field is wrong (details.field names it), or you mixed test and live wallets. |
| 429 | RATE_LIMITED, MSISDN_RATE_LIMITED | Too many requests. Wait details.retry_after_seconds. |
| 5xx | SERVER_ERROR | Our side. Safe to retry with the same Idempotency-Key. |
Idempotency
Network problems happen. To retry a POST safely, send an Idempotency-Key header (8–80 letters, digits, - or _). Retrying with the same key and body returns the original response with an Idempotent-Replayed: true header, and never moves money twice. Reusing a key with a different body returns 422 IDEMPOTENCY_MISMATCH. Keys are kept for 24 hours.
Money-moving calls also require your own unique reference (8–36 characters). A second request with the same reference returns 409 DUPLICATE_REFERENCE.
Rate limits
Each key can make 120 requests per minute. Separately, the mobile networks allow at most 5 payment requests per phone number every 10 minutes. A sixth request returns 429 MSISDN_RATE_LIMITED with retry_after_seconds, so show your customer when they can try again rather than retrying in a loop.
List wallets
Permission wallets:read. Optional query parameters: external_ref (find the wallet for one of your customers), parent_wallet_id, limit (1–100, default 50), starting_after (a wallet id, for paging).
curl "https://tiinypay.com/v1/wallets?external_ref=shop-0192" \
-H "Authorization: Bearer $TIINYPAY_KEY"
Create a sub-wallet
Permission wallets:write. Main wallets are created in the dashboard; the API creates sub-wallets under them.
| Field | Description |
|---|---|
parent_wallet_id required | The main wallet this sits under. |
name required | 2–120 characters. |
external_ref | Your own ID for this customer. Unique per business, so you can look the wallet up later. |
deposit_fee_bearer | wallet or customer. Defaults to the parent's setting. |
curl "https://tiinypay.com/v1/wallets" -X POST \
-H "Authorization: Bearer $TIINYPAY_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: create-shop-0192" \
-d '{
"parent_wallet_id": "wal_8fK2mQx7RtP4nVb9LcHs2w",
"name": "Nakasero Pharmacy",
"external_ref": "shop-0192",
"deposit_fee_bearer": "customer"
}'
Response 201
{
"success": true,
"data": {
"id": "wal_Zq4Hn8YtV2kPm6RbX3cLw9",
"kind": "sub",
"parent_wallet_id": "wal_8fK2mQx7RtP4nVb9LcHs2w",
"name": "Nakasero Pharmacy",
"external_ref": "shop-0192",
"currency": "UGX",
"balance": "0.00",
"deposit_fee_bearer": "customer",
"status": "active",
"created_at": "2026-10-04T14:20:11+03:00"
}
}
Wallet balance
Permission wallets:read. The wallet id is required: a key never sees a combined balance it isn't allowed to.
curl "https://tiinypay.com/v1/wallets/wal_Zq4Hn8YtV2kPm6RbX3cLw9" \
-H "Authorization: Bearer $TIINYPAY_KEY"
Response 200
{ "success": true, "data": { "id": "wal_Zq4Hn8YtV2kPm6RbX3cLw9", "balance": "48500.00", "currency": "UGX", "status": "active", ... } }
Wallet transactions
Permission transactions:read. Newest first. Query: limit (1–100, default 25) and starting_after (a transaction id) to page backwards. The response has items and has_more.
Quote fees
Permission wallets:read. Shows what a collection or payout will cost before you make it. Nothing is created.
curl "https://tiinypay.com/v1/quotes" -X POST \
-H "Authorization: Bearer $TIINYPAY_KEY" -H "Content-Type: application/json" \
-d '{ "type": "payout", "wallet_id": "wal_Zq4Hn8YtV2kPm6RbX3cLw9", "amount": 20000, "msisdn": "+256771234567" }'
{ "success": true, "data": {
"type": "payout", "amount": "20000.00", "estimated_fees": "800.00",
"amount_held": "20800.00", "wallet_balance": "48500.00", "sufficient_funds": true } }
Collect a payment
Permission deposits:write. Sends a payment prompt to the customer's phone. The wallet is credited only after the customer approves and the network confirms.
| Field | Description |
|---|---|
wallet_id required | Wallet to credit. |
msisdn required | Payer's number. +256771234567, 256771234567 and 0771234567 are accepted. |
amount required | Whole UGX, 500 – 5,000,000. |
reference required | Your unique reference, 8–36 characters. |
fee_bearer | wallet or customer. Defaults to the wallet's setting. |
description | Up to 100 characters. |
metadata | Up to 20 string keys with simple values. Returned on the transaction and in webhooks. |
curl "https://tiinypay.com/v1/deposits" -X POST \
-H "Authorization: Bearer $TIINYPAY_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-77812" \
-d '{
"wallet_id": "wal_Zq4Hn8YtV2kPm6RbX3cLw9",
"msisdn": "+256771234567",
"amount": 10000,
"reference": "order-77812",
"fee_bearer": "customer",
"description": "Order 77812",
"metadata": { "order_id": "77812" }
}'
Response 201
{
"success": true,
"data": {
"id": "txn_Rk3Lp9WmQ2vX7bN4cTz8Hd",
"type": "deposit",
"status": "processing",
"wallet_id": "wal_Zq4Hn8YtV2kPm6RbX3cLw9",
"amount": "10000.00",
"customer_charged": "10310.00",
"fee_bearer": "customer",
"net": null,
"msisdn": "+256771234567",
"provider": "MTN_UGANDA",
"fees": { "provider": null, "provider_estimated": "309.30", "platform": "0.00" },
"reference": "order-77812",
...
}
}
Status lifecycle
processing means the prompt is on the customer's phone. Wait for the deposit.succeeded or deposit.failed webhook, or poll Get a transaction. A rare review status means the network reported something unexpected (for example, a different amount) and our team is checking it; no money is credited until it's resolved.
Send a payout
Permission payouts:write and an IP allowlist on the key. Same fields as a collection, except fee_bearer. The wallet pays the network fee.
curl "https://tiinypay.com/v1/payouts" -X POST \
-H "Authorization: Bearer $TIINYPAY_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: withdraw-5521" \
-d '{
"wallet_id": "wal_Zq4Hn8YtV2kPm6RbX3cLw9",
"msisdn": "+256701234567",
"amount": 20000,
"reference": "withdraw-5521",
"description": "Shop withdrawal"
}'
The response includes amount_held, the amount plus the fee estimate taken from the wallet immediately. On payout.succeeded, fees.provider holds the real fee and net the final change to the wallet. On payout.failed, the full hold returns to the wallet.
Payouts are never marked failed just because the network is slow. If the outcome is unclear, the payout stays processing until the network confirms it, and moves to review after 24 hours.
Transfer between wallets
Permission transfers:write. Instant and free of network fees. Both wallets must belong to your business and be visible to the key.
| Field | Description |
|---|---|
from_wallet_id required | Wallet to debit. |
to_wallet_id required | Wallet to credit. |
amount required | Whole UGX, 1 – 50,000,000. |
reference required | Your unique reference. |
fee | Optional fee you charge the sender, in UGX. Debited from the sender on top of amount. |
fee_wallet_id | Where the fee goes. Defaults to the sender's main wallet. |
description, metadata | As above. |
Example: a peer-to-peer app charging 1% (UGX 500 on 50,000), collected into its main wallet:
curl "https://tiinypay.com/v1/transfers" -X POST \
-H "Authorization: Bearer $TIINYPAY_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: p2p-90311" \
-d '{
"from_wallet_id": "wal_Zq4Hn8YtV2kPm6RbX3cLw9",
"to_wallet_id": "wal_Pt7Kc2NxW9mQ4vR8bLd3Hy",
"amount": 50000,
"fee": 500,
"reference": "p2p-90311",
"description": "Send 50k to John"
}'
The response is the sender's transfer_out transaction with status: "success". The receiver gets a matching transfer_in, and the fee wallet a fee_in.
Verify a phone number
Permission msisdn:validate. Returns the registered name so your customer can confirm who they're paying. MTN and Airtel Uganda only. Limited to 30 checks per minute per key.
curl "https://tiinypay.com/v1/msisdn/validate" -X POST \
-H "Authorization: Bearer $TIINYPAY_KEY" -H "Content-Type: application/json" \
-d '{ "msisdn": "0771234567" }'
{ "success": true, "data": { "msisdn": "+256771234567", "valid": true, "customer_name": "JOHN DOE", "provider": "MTN_UGANDA", "message": null } }
Get a transaction
Permission transactions:read. Use it to poll if you can't receive webhooks; every 10–30 seconds is plenty.
| Status | Meaning |
|---|---|
pending | Created, not yet accepted by the network. |
processing | With the network, waiting for the customer or the network to finish. |
success | Done. The wallet balance has changed by net. |
failed | Didn't happen. Any held funds are back in the wallet. See failure_reason. |
review | Being checked by TiinyPay. Final status follows. |
Webhooks
Set a notification URL in the dashboard under Developers. We POST JSON to it for these events:
| Event | When |
|---|---|
deposit.succeeded | The customer paid and the wallet was credited. |
deposit.failed | Declined, timed out or cancelled. |
payout.succeeded | The recipient was paid. |
payout.failed | The payout failed and funds returned to the wallet. |
transfer.succeeded | A wallet-to-wallet transfer completed. |
transaction.review | A transaction needs a manual check. |
POST /webhooks/tiinypay
TiinyPay-Event: deposit.succeeded
TiinyPay-Delivery: evt_Mx8Qp3VnK7rT2bWc9LhZ4d
TiinyPay-Signature: t=1791127650,v1=5e8f0c7a...
{
"id": "evt_Mx8Qp3VnK7rT2bWc9LhZ4d",
"event": "deposit.succeeded",
"created_at": "2026-10-04T14:07:51+03:00",
"data": { "id": "txn_Rk3Lp9WmQ2vX7bN4cTz8Hd", "status": "success", "net": "10000.00", ... }
}
Respond with any 2xx within 10 seconds. Otherwise we retry after 1 min, 5 min, 15 min, 1 h, 2 h, 6 h, 12 h and 24 h. The same event can arrive more than once, so store the event id and ignore repeats. Redirects are not followed, and URLs must be public https:// addresses.
Verifying signatures
Always verify the signature before trusting a webhook. Compute HMAC-SHA256 of t + "." + raw_body with your signing secret and compare it to v1 in constant time. Reject timestamps more than 5 minutes old.
<?php
$secret = getenv('TIINYPAY_WEBHOOK_SECRET'); // whsec_...
$body = file_get_contents('php://input'); // raw body, before json_decode
$header = $_SERVER['HTTP_TIINYPAY_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $sig); // ['t' => ..., 'v1' => ...]
$expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $body, $secret);
if (!hash_equals($expected, $sig['v1'] ?? '') || abs(time() - (int) ($sig['t'] ?? 0)) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
// Deduplicate on $event['id'], then update your order using $event['data']['reference'].
http_response_code(200);
Treat the webhook as a signal: for high-value actions, confirm with GET /v1/transactions/{id} before releasing goods.