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:

https://tiinypay.com/v1

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:

ControlWhat it does
Permissionswallets:read, wallets:write, deposits:write, payouts:write, transfers:write, transactions:read, msisdn:validate. A call without the needed permission returns 403 INSUFFICIENT_SCOPE.
WalletsOptionally 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 IPsOptional 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

NumberWhat happens
+256700000002The customer declines (collections) or the payout fails. Funds held for a payout are returned.
+256700000003Stays processing for about 60 seconds, then succeeds. Use it to test polling and webhooks.
+256700000004Not a registered mobile money number: rejected immediately, and fails verification.
Any other valid numberSucceeds 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:

ValueCustomer is asked forWallet receives
walletThe amount you send, e.g. 10,000Amount minus the fee, e.g. 9,700
customerAmount plus the fee, e.g. 10,310About 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"
}
HTTPCodeMeaning
401UNAUTHENTICATED, KEY_REVOKEDMissing, wrong, revoked or expired key.
402INSUFFICIENT_FUNDSThe wallet balance can't cover the amount and fees.
403INSUFFICIENT_SCOPE, IP_NOT_ALLOWED, LIVE_NOT_ENABLEDThe key or business isn't allowed to do this.
403WEEKLY_LIMIT_REACHEDThe payout would exceed your 7-day live payout limit. details.remaining shows what's left.
404WALLET_NOT_FOUND, NOT_FOUNDDoesn't exist, or this key can't see it.
409DUPLICATE_REFERENCE, WALLET_NOT_ACTIVEReference already used (the existing transaction_id is in details), or wallet is frozen.
422VALIDATION_ERROR, MODE_MISMATCHA field is wrong (details.field names it), or you mixed test and live wallets.
429RATE_LIMITED, MSISDN_RATE_LIMITEDToo many requests. Wait details.retry_after_seconds.
5xxSERVER_ERROROur 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

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

POST/v1/wallets

Permission wallets:write. Main wallets are created in the dashboard; the API creates sub-wallets under them.

FieldDescription
parent_wallet_id requiredThe main wallet this sits under.
name required2–120 characters.
external_refYour own ID for this customer. Unique per business, so you can look the wallet up later.
deposit_fee_bearerwallet 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

GET/v1/wallets/{wallet_id}

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

GET/v1/wallets/{wallet_id}/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

POST/v1/quotes

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

POST/v1/deposits

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.

FieldDescription
wallet_id requiredWallet to credit.
msisdn requiredPayer's number. +256771234567, 256771234567 and 0771234567 are accepted.
amount requiredWhole UGX, 500 – 5,000,000.
reference requiredYour unique reference, 8–36 characters.
fee_bearerwallet or customer. Defaults to the wallet's setting.
descriptionUp to 100 characters.
metadataUp 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

pendingprocessingsuccess | failed

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

POST/v1/payouts

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

POST/v1/transfers

Permission transfers:write. Instant and free of network fees. Both wallets must belong to your business and be visible to the key.

FieldDescription
from_wallet_id requiredWallet to debit.
to_wallet_id requiredWallet to credit.
amount requiredWhole UGX, 1 – 50,000,000.
reference requiredYour unique reference.
feeOptional fee you charge the sender, in UGX. Debited from the sender on top of amount.
fee_wallet_idWhere the fee goes. Defaults to the sender's main wallet.
description, metadataAs 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

POST/v1/msisdn/validate

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

GET/v1/transactions/{transaction_id}

Permission transactions:read. Use it to poll if you can't receive webhooks; every 10–30 seconds is plenty.

StatusMeaning
pendingCreated, not yet accepted by the network.
processingWith the network, waiting for the customer or the network to finish.
successDone. The wallet balance has changed by net.
failedDidn't happen. Any held funds are back in the wallet. See failure_reason.
reviewBeing checked by TiinyPay. Final status follows.

Webhooks

Set a notification URL in the dashboard under Developers. We POST JSON to it for these events:

EventWhen
deposit.succeededThe customer paid and the wallet was credited.
deposit.failedDeclined, timed out or cancelled.
payout.succeededThe recipient was paid.
payout.failedThe payout failed and funds returned to the wallet.
transfer.succeededA wallet-to-wallet transfer completed.
transaction.reviewA 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.