Payout API
Pay out instantly to mobile wallets and bank accounts in Kenya, Rwanda, Uganda, Ethiopia and China from a pre-funded USD, EUR or AED balance, without opening an account in any of those countries.
How it works
- You sign a contract, pass MIAlink's business checks and get an API key.
- You pre-fund a balance in USD, EUR or AED by bank transfer. Each currency is a separate balance: a payout on a
USD-…corridor is paid from your USD balance, on aEUR-…corridor from your EUR balance. MIAlink never converts between your balances. - For each payout you ask for a quote, then send the payout with the recipient and your customer (the sender).
- MIAlink checks the recipient's registered name where the network allows, screens both people against sanctions lists, and pays from its own account in that country.
- Your balance is debited at once. If the payout fails, the full amount and fee go straight back to your balance.
Base address: https://YOUR-MIALINK-DOMAIN/api/v1/b2b. All amounts are strings with up to two decimals. Times are UTC.
Your API key
Send your key in the X-Api-Key header on every request. Keys start with mlk_test_ in sandbox and mlk_live_ in production. Keep it on your servers only; never put it in a mobile app or web page. You can replace it at any time under Developers in the client portal; the old key stops working immediately.
curl https://YOUR-MIALINK-DOMAIN/api/v1/b2b/account \
-H "X-Api-Key: mlk_test_…"
First payout in 4 calls
# 1. How much can I pay out?
GET /api/v1/b2b/account
# 2. Lock a price for 60 seconds
POST /api/v1/b2b/quotes {"corridor": "USD-KE", "send_amount": "150.00"}
# 3. Pay (with a unique Idempotency-Key)
POST /api/v1/b2b/payouts Idempotency-Key: 7c1e… {"quote_id": "…", "recipient": {…}, "sender": {…}}
# 4. Follow it: webhooks to your server, or poll
GET /api/v1/b2b/payouts/{your client_reference}
Account and balance
{
"client_id": "4b0e…", "name": "Gulf Wallet Co", "status": "active",
"balances": {"USD": "47978.75", "EUR": "19620.40"},
"price": {"fee_per_payout_usd": "0.7500", "fx_margin": "0.0040000000"},
"daily_limit_usd": "250000.00"
}
Destinations
Lists the destinations live for you now, the currency you fund in, the currency paid out and the ways to pay.
| Corridor | Pays out | Ways to pay (payout_method) |
|---|---|---|
USD-KE | KES, whole shillings | mpesa, airtel_ke, ke_bank |
USD-RW | RWF | mtn_momo_rw, airtel_rw, rw_bank |
USD-ET | ETB | telebirr, mpesa_et, et_bank |
USD-CN | CNY | alipay, wechat, unionpay, cn_bank |
USD-UG | UGX, whole shillings | mtn_momo_ug, airtel_ug |
EUR-KE | KES, whole shillings | mpesa, airtel_ke, ke_bank |
EUR-RW | RWF | mtn_momo_rw, airtel_rw, rw_bank |
EUR-UG | UGX, whole shillings | mtn_momo_ug, airtel_ug |
A destination appears only once MIAlink has switched it on for the payout API. Each row also lists available_methods: the ways to pay connected right now. Ways to pay that are not connected yet are refused with a clear message. EUR corridors are priced from the live EUR rate to the local currency; your fee per payout is agreed in USD and charged in euros at the day's rate.
Mobile numbers are checked before anything is quoted or paid: Kenya +254 7… / 1…, Rwanda +250 7…, Uganda +256 7…, Ethiopia +251 9… / 7…. You can send them with or without the plus sign, spaces or a leading 0.
Check a recipient
{"full_name": "Uwase Aline", "country": "RW", "payout_method": "mtn_momo_rw", "account_number": "250788123456"}
→ {"registered_name": "Uwase Aline", "match": true, "name_check": "checked"}
MTN MoMo returns the registered name before you pay. M-Pesa does not offer this; there name_check is not_offered_by_network and the registered name is recorded on the payout once paid. When names differ, the payout is held for MIAlink compliance instead of being paid.
Quotes
Give send_amount (what leaves your balance before the fee) or receive_amount (what the recipient gets). The rate is fixed for 60 seconds and a quote can be used once.
{"corridor": "USD-KE", "send_amount": "150.00"}
→ {"id": "q_…", "send_amount": "150.00", "send_currency": "USD", "fee": "0.75", "total_charge": "150.75",
"customer_rate": "128.9820", "receive_amount": "19347", "receive_currency": "KES",
"expires_at": "2026-10-19T14:35:12Z"}
Payouts
Idempotency-Key: 2f0c5d1e-8a7b-4c4e-9d55-0b6f2b6a1c11
{
"quote_id": "q_…",
"client_reference": "ORD-10231",
"purpose": "family_support",
"recipient": {"full_name": "John Otieno", "country": "KE", "payout_method": "mpesa",
"account_number": "254712345678"},
"sender": {"full_name": "Ahmed Ali", "country": "SA", "id_number": "2345678901"}
}
purpose is one of family_support, education, medical, savings, gift, supplier_payment, salary, business_services, other. The sender is your customer. Cross-border rules require their name and country on every payout; send their ID number where you hold it. The answer is the payout with its current status and history.
Look up any payout by MIAlink's id or by your own reference.
Bulk payouts
Up to 1,000 payouts in one call: payroll runs, freelancer and marketplace payouts. You do not ask for quotes first: each item is quoted at the current rate and paid at once. Every item is checked exactly like a single payout (recipient number, sanctions screening on both people, your daily limit and your balance) and gets its own result, so one bad item never stops the others.
Idempotency-Key: payroll-2026-10 (one key for the whole batch)
{"items": [
{"client_reference": "PAY-OCT-001", "corridor": "USD-KE", "send_amount": "120.00", "purpose": "salary",
"recipient": {"full_name": "Achieng Odhiambo", "country": "KE", "payout_method": "mpesa", "account_number": "254711000101"},
"sender": {"full_name": "Your Company Ltd", "country": "AE"}},
{"client_reference": "PAY-OCT-002", "corridor": "EUR-UG", "receive_amount": "450000", "purpose": "salary",
"recipient": {"full_name": "Nalwoga Grace", "country": "UG", "payout_method": "mtn_momo_ug", "account_number": "0772000103"},
"sender": {"full_name": "Your Company Ltd", "country": "AE"}},
{"client_reference": "PAY-OCT-003", "corridor": "USD-KE", "send_amount": "300.00", "purpose": "salary",
"recipient": {"full_name": "Mary Njeri", "country": "KE", "payout_method": "ke_bank",
"account_number": "0123456789012", "bank_code": "68"},
"sender": {"full_name": "Your Company Ltd", "country": "AE"}}
]}
→ {"batch_key": "payroll-2026-10", "items": 3, "accepted": 2, "not_accepted": 1, "results": [
{"index": 0, "client_reference": "PAY-OCT-001", "ok": true, "http_status": 200, "payout_id": "…",
"status": "paid", "replayed": false, "send_amount": "120.00", "send_currency": "USD", "fee": "0.75",
"receive_amount": "15418", "receive_currency": "KES", "failure_reason": null},
{"index": 1, "client_reference": "PAY-OCT-002", "ok": true, "status": "paid", …},
{"index": 2, "client_reference": "PAY-OCT-003", "ok": false, "http_status": 200, "payout_id": "…",
"status": "rejected", "failure_reason": "insufficient_client_balance"}]}
- Each item needs its own
client_reference(1 to 100 characters) and exactly one ofsend_amountorreceive_amount. The other fields are those of a single payout;purposedefaults tofamily_support, so sendsalaryfor payroll. - Never pays twice. The batch
Idempotency-Keyplus each item'sclient_referenceidentifies the item. Sending the same batch again with the same key returns the payouts already made ("replayed": true) and pays nothing again. Aclient_referencealready used by another payout is refused (http_status409, with that payout's id), so a payroll run re-sent under a new key cannot pay anyone twice. - Per-item results. The call answers 200 when the batch was read. Look at each result:
okis true when a payout was created and not rejected;statusthen follows the usual payout statuses and every change is sent as a status update. Items that could not be created carryhttp_status(400 invalid details, 403 destination not live, 409 duplicate reference, 422 malformed item) and anerror. - Items are paid in the order sent; your balance and daily limit are checked item by item, so when money runs out, the later items are
rejectedwithinsufficient_client_balanceand nothing is half-paid. - A large batch takes a while to answer (each item is paid before the next). Allow at least 120 seconds; if the connection drops, send the same batch with the same key to get the results.
The client portal offers the same thing as a CSV upload (Send money, then Many payouts from a file), with a check of every row before anything is paid and a downloadable result per row.
Kenyan bank accounts
Pay any account at a listed Kenyan bank with payout_method ke_bank. Send the account number (6 to 20 digits) and bank_code: the 2-digit Central Bank of Kenya code (for example 68 Equity, 01 KCB, 11 Co-operative Bank) or the 5-digit bank and branch code. This call lists the codes accepted.
"recipient": {"full_name": "Mary Njeri", "country": "KE", "payout_method": "ke_bank",
"account_number": "0123456789012", "bank_code": "68"}
Bank payouts are available in sandbox now. In production they open once MIAlink's bank payout partner is connected; until then ke_bank is not in available_methods and payouts to it are refused before any money moves.
Payout statuses
| Status | Meaning | Final? |
|---|---|---|
created | Received and being checked | No |
compliance_review | Held for a MIAlink compliance officer (for example a name difference or a possible sanctions match). Money stays reserved. | No |
funded | Taken from your balance | No |
awaiting_liquidity | Waiting a few moments for MIAlink's country account to be topped up | No |
payout_pending | With the network, waiting for its confirmation | No |
paid | The recipient has the money | Yes |
failed then refunded | The network refused it; amount and fee are back in your balance. failure_reason says why. | Yes |
rejected | Refused before any money moved (for example balance too low, limit reached, sanctions match) | Yes |
If a network does not answer, the payout stays payout_pending; MIAlink never refunds a payout that might have been paid. It asks the network for the result until it is known.
Status updates
Every status change is pushed to your webhook address (set it in the client portal) and is also available to poll.
POST https://your-server/mialink (what MIAlink sends you)
X-MIAlink-Signature: 6f1b… (HMAC-SHA256 of the raw body with your signing secret)
X-MIAlink-Event-Id: 1042
{"payout_id": "…", "client_reference": "ORD-10231", "status": "paid", "note": "confirmed by M-Pesa, receipt QK7…",
"receive_amount": "19347", "receive_currency": "KES", "failure_reason": null, "occurred_at": "2026-10-19T14:34:58Z"}
Answer with any 2xx status within 10 seconds. Otherwise MIAlink retries after 1, 5, 15 and 60 minutes, then 3, 6, 12 and 24 hours. Events can arrive more than once: use X-MIAlink-Event-Id to ignore repeats.
Never pay twice
Every POST /payouts needs an Idempotency-Key header (1 to 100 characters, unique per payout). If your request times out, send exactly the same request with the same key: you get the original payout back and nothing is paid twice. Reusing a key for a different payout is refused. For bulk payouts the key (1 to 80 characters) covers the batch and each item's client_reference makes the item unique.
Verifying webhooks
Compute HMAC-SHA256 of the raw request body with your signing secret and compare it with X-MIAlink-Signature in constant time. Reject anything that does not match.
# Python
import hmac, hashlib
def is_from_mialink(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
// Node.js
const crypto = require("crypto");
function isFromMialink(rawBody, signature, secret) {
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
Errors and limits
| HTTP | When |
|---|---|
| 400 | Something in the request is wrong; detail says what |
| 401 | API key missing or wrong |
| 403 | Account not active yet, or destination not live |
| 404 | Payout or quote not found |
| 409 | Quote expired or already used, or idempotency key reused |
| 422 | The request body does not have the documented shape (for a batch: no items, or more than 1,000) |
| 429 | More than 600 requests a minute; wait for Retry-After seconds |
Every response carries an X-Request-Id. Quote it when you contact MIAlink support.
Testing in sandbox
Sandbox keys move no real money. Use these account numbers to see each outcome:
| Account number ends in | Result |
|---|---|
0000 | Network refuses: failed, then refunded |
9999 | Stays payout_pending until the result arrives |
1111 | Registered name differs: held in compliance_review |
| anything else | paid at once |
Sender or recipient named Sanctioned Person is refused as a sanctions match.
MIAlink is a product of MIA Global Technologies. Confidential to MIAlink clients.