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

  1. You sign a contract, pass MIAlink's business checks and get an API key.
  2. 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 a EUR-… corridor from your EUR balance. MIAlink never converts between your balances.
  3. For each payout you ask for a quote, then send the payout with the recipient and your customer (the sender).
  4. 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.
  5. 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

GET/account
{
  "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

GET/corridors

Lists the destinations live for you now, the currency you fund in, the currency paid out and the ways to pay.

CorridorPays outWays to pay (payout_method)
USD-KEKES, whole shillingsmpesa, airtel_ke, ke_bank
USD-RWRWFmtn_momo_rw, airtel_rw, rw_bank
USD-ETETBtelebirr, mpesa_et, et_bank
USD-CNCNYalipay, wechat, unionpay, cn_bank
USD-UGUGX, whole shillingsmtn_momo_ug, airtel_ug
EUR-KEKES, whole shillingsmpesa, airtel_ke, ke_bank
EUR-RWRWFmtn_momo_rw, airtel_rw, rw_bank
EUR-UGUGX, whole shillingsmtn_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

POST/recipients/verify
{"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

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

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

GET/payouts/{id or client_reference}

Look up any payout by MIAlink's id or by your own reference.

Bulk payouts

POST/payouts/batch

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"}]}

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

GET/banks?country=KE

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

StatusMeaningFinal?
createdReceived and being checkedNo
compliance_reviewHeld for a MIAlink compliance officer (for example a name difference or a possible sanctions match). Money stays reserved.No
fundedTaken from your balanceNo
awaiting_liquidityWaiting a few moments for MIAlink's country account to be topped upNo
payout_pendingWith the network, waiting for its confirmationNo
paidThe recipient has the moneyYes
failed then refundedThe network refused it; amount and fee are back in your balance. failure_reason says why.Yes
rejectedRefused 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.

GET/events?after={last event id}
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

HTTPWhen
400Something in the request is wrong; detail says what
401API key missing or wrong
403Account not active yet, or destination not live
404Payout or quote not found
409Quote expired or already used, or idempotency key reused
422The request body does not have the documented shape (for a batch: no items, or more than 1,000)
429More 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 inResult
0000Network refuses: failed, then refunded
9999Stays payout_pending until the result arrives
1111Registered name differs: held in compliance_review
anything elsepaid 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.