Integrate mobile money into any application.
YiyoPay is a REST API for collecting from and paying out to MTN Mobile Money and Airtel Money wallets in Uganda. Everything you need to accept payments, send payouts, and reconcile — in a few lines of code.
Introduction
The YiyoPay API lets any external system — your e-commerce store, POS, school-management system, payroll app, ERP, or custom internal tool — move money via mobile money in Uganda.
What you can do
- Collect payments from customers — a PIN prompt appears on their phone; they approve; money lands in your account.
- Send payouts to any MTN or Airtel wallet — for salaries, refunds, supplier payments, agent commissions.
- Reconcile automatically — every transaction ties back to a customer, invoice, and settlement.
- Get notified in real time — webhooks push status changes to your endpoints so you don't have to poll.
Base URL
https://yiyopay.comAll API paths start with /api/v1/. Same base URL for sandbox and live — the environment is inferred from your API key prefix.
Every payment is called a payment intent. It's created, moves through processing, and ends in successful or failed. That's it. Everything else in the API supports that lifecycle.
API keys
Each organisation gets two secret keys — one per environment. You'll get them at Developers → API Keys after you sign up.
| Environment | Prefix | Behaviour |
|---|---|---|
| Sandbox | sk_sandbox_... |
All requests are simulated. Payments always succeed (unless you use the test phone numbers below to force specific failures). No real money moves. Safe for CI and automated tests. |
| Live | sk_live_... |
Hits real MTN and Airtel APIs. Real money moves. Requires verified KYC before you can go live. |
Store your key in an environment variable (YIYOPAY_KEY) or a secret manager. If you lose it, revoke and generate a new one. Never commit API keys to git.
Authentication
Send the key as a Bearer token on every request:
Authorization: Bearer sk_live_a1b2c3d4e5f6789...
The environment (sandbox vs live) is inferred from the prefix. Missing/invalid tokens return 401 Unauthorized.
Your first call — 5 minutes
Request 5,000 UGX from a customer's Airtel Money wallet. Their phone will get a PIN prompt within seconds.
# Request 5,000 UGX from a customer's Airtel Money wallet curl -X POST https://yiyopay.com/api/v1/payments \ -H "Authorization: Bearer $YIYOPAY_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-1001" \ -d '{ "amount": 5000, "currency": "UGX", "payer_phone": "0700123456", "payer_name": "Jane Doe", "purpose": "Order #1001" }'
<?php $ch = curl_init('https://yiyopay.com/api/v1/payments'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('YIYOPAY_KEY'), 'Content-Type: application/json', 'Idempotency-Key: order-1001', ], CURLOPT_POSTFIELDS => json_encode([ 'amount' => 5000, 'payer_phone' => '0700123456', 'purpose' => 'Order #1001', ]), ]); $response = json_decode(curl_exec($ch), true); echo $response['data']['payment']['yiyopay_ref'];
const res = await fetch('https://yiyopay.com/api/v1/payments', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.YIYOPAY_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': 'order-1001', }, body: JSON.stringify({ amount: 5000, payer_phone: '0700123456', purpose: 'Order #1001', }), }); const { data: { payment } } = await res.json(); console.log(payment.yiyopay_ref);
import os, requests r = requests.post( "https://yiyopay.com/api/v1/payments", headers={ "Authorization": f"Bearer {os.environ['YIYOPAY_KEY']}", "Idempotency-Key": "order-1001", }, json={"amount": 5000, "payer_phone": "0700123456", "purpose": "Order #1001"}, ) payment = r.json()["data"]["payment"] print(payment["yiyopay_ref"])
Response
{
"success": true,
"message": "",
"data": {
"payment": {
"id": 1234,
"yiyopay_ref": "YPCOL2508259A3B1C",
"status": "processing",
"amount": 5000,
"fee_amount": 125,
"total_amount": 5125,
"merchant_net": 5000,
"currency": "UGX",
"payer_phone": "256700123456",
"provider": "airtel",
"created_at": "2025-08-26T14:22:11Z"
}
}
}
Collecting money — request-to-pay
/api/v1/paymentsRequest body
| Field | Type | Notes |
|---|---|---|
amount * | number | Amount in shillings (integer). Min 500 UGX. |
payer_phone * | string | Local (07XXXXXXXX) or E.164 (2567XXXXXXXX). Provider auto-detected. |
currency | string | Defaults to UGX. |
payer_name | string | Shown on receipts and your dashboard. |
purpose | string | Free-text description. |
customer_id | integer | Link to an existing customer record. |
reference | string | Your internal reference (invoice number, order ID, etc.). |
provider | string | Force mtn or airtel — otherwise auto-detected from the phone prefix. |
callback_url | string | Override the org-level webhook URL for this payment only. |
Safe retries — idempotency
Network hiccups happen. Add an Idempotency-Key header (up to 128 chars) so retried requests return the original response instead of double-charging the customer.
curl -X POST https://yiyopay.com/api/v1/payments \ -H "Authorization: Bearer $YIYOPAY_KEY" \ -H "Idempotency-Key: order-1001-attempt-1" \ -H "Content-Type: application/json" \ -d '{"amount":10000,"payer_phone":"0772000000","purpose":"Invoice #42"}' # Retry the exact same call — you get the original response, no new charge curl -X POST https://yiyopay.com/api/v1/payments \ -H "Authorization: Bearer $YIYOPAY_KEY" \ -H "Idempotency-Key: order-1001-attempt-1" \ -H "Content-Type: application/json" \ -d '{"amount":10000,"payer_phone":"0772000000","purpose":"Invoice #42"}'
// Use your order ID as the idempotency key function collect($order_id, $amount, $phone) { $ch = curl_init('https://yiyopay.com/api/v1/payments'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('YIYOPAY_KEY'), 'Idempotency-Key: order-' . $order_id, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'amount'=>$amount, 'payer_phone'=>$phone, 'purpose'=>"Order #{$order_id}", ]), ]); return json_decode(curl_exec($ch), true); }
Use a stable identifier from your side — an order ID, an invoice number, a job ID. Don't use timestamps or random values, or you defeat the purpose.
Polling for status
If you can't accept webhooks, poll the payment endpoint every 3–5 seconds until it reaches a terminal state.
/api/v1/payments/{yiyopay_ref}// Poll until terminal state or 60 seconds function wait_for_payment($ref, $max_seconds = 60) { $deadline = time() + $max_seconds; while (time() < $deadline) { $ch = curl_init('https://yiyopay.com/api/v1/payments/' . $ref); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . getenv('YIYOPAY_KEY')], ]); $p = json_decode(curl_exec($ch), true)['data']['payment']; if (in_array($p['status'], ['successful', 'failed', 'cancelled', 'expired'])) { return $p; } sleep(3); } throw new \Exception('Payment timed out'); }
import os, time, requests def wait_for_payment(ref, max_seconds=60): deadline = time.time() + max_seconds while time.time() < deadline: r = requests.get( f"https://yiyopay.com/api/v1/payments/{ref}", headers={"Authorization": f"Bearer {os.environ['YIYOPAY_KEY']}"}, ) p = r.json()["data"]["payment"] if p["status"] in ("successful", "failed", "cancelled", "expired"): return p time.sleep(3) raise TimeoutError("Payment timed out")
async function waitForPayment(ref, maxSeconds = 60) { const deadline = Date.now() + maxSeconds * 1000; while (Date.now() < deadline) { const r = await fetch(`https://yiyopay.com/api/v1/payments/${ref}`, { headers: { Authorization: `Bearer ${process.env.YIYOPAY_KEY}` } }); const { data: { payment } } = await r.json(); if (['successful', 'failed', 'cancelled', 'expired'].includes(payment.status)) { return payment; } await new Promise(r => setTimeout(r, 3000)); } throw new Error('Payment timed out'); }
Polling wastes API quota and adds latency. Use polling as a fallback, not the main flow.
Payment links — hosted checkout
Don't want to handle phone numbers yourself? Create a payment link and redirect the customer to it. YiyoPay handles the checkout UI and shows a success page.
/api/v1/payment-links# Fixed-amount link (customer just approves) curl -X POST https://yiyopay.com/api/v1/payment-links \ -H "Authorization: Bearer $YIYOPAY_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "fixed", "title": "Course fee — Term 3", "amount": 250000, "description": "Grade 5 tuition · Bright Future Academy" }' # Open link (customer chooses amount) curl -X POST https://yiyopay.com/api/v1/payment-links \ -H "Authorization: Bearer $YIYOPAY_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "open", "title": "Donate to our cause" }'
// After creating, redirect the customer to the returned URL $link = yp_create_link('Order #1001', 10000); header('Location: ' . $link['url']); exit;
Sending money — single disbursement
/api/v1/disbursementsPay money out to any MTN or Airtel wallet. Deducted from your platform balance (net of unsettled collections minus in-flight payouts).
curl -X POST https://yiyopay.com/api/v1/disbursements \ -H "Authorization: Bearer $YIYOPAY_KEY" \ -H "Idempotency-Key: payout-2025-089" \ -H "Content-Type: application/json" \ -d '{ "recipient_phone": "0772987654", "recipient_name": "John Kato", "amount": 50000, "purpose": "Supplier payment · INV-2025-089" }'
function pay_supplier($invoice_id, $phone, $name, $amount) { $ch = curl_init('https://yiyopay.com/api/v1/disbursements'); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => [ 'Authorization: Bearer ' . getenv('YIYOPAY_KEY'), 'Idempotency-Key: payout-' . $invoice_id, 'Content-Type: application/json', ], CURLOPT_POSTFIELDS => json_encode([ 'recipient_phone' => $phone, 'recipient_name' => $name, 'amount' => $amount, 'purpose' => "Supplier payment · INV-{$invoice_id}", ]), ]); return json_decode(curl_exec($ch), true); }
async function paySupplier({ invoiceId, phone, name, amount }) { const res = await fetch('https://yiyopay.com/api/v1/disbursements', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.YIYOPAY_KEY}`, 'Idempotency-Key': `payout-${invoiceId}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ recipient_phone: phone, recipient_name: name, amount, purpose: `Supplier payment · INV-${invoiceId}`, }), }); return res.json(); }
import os, requests def pay_supplier(invoice_id, phone, name, amount): r = requests.post( "https://yiyopay.com/api/v1/disbursements", headers={ "Authorization": f"Bearer {os.environ['YIYOPAY_KEY']}", "Idempotency-Key": f"payout-{invoice_id}", }, json={ "recipient_phone": phone, "recipient_name": name, "amount": amount, "purpose": f"Supplier payment · INV-{invoice_id}", }, ) return r.json()
Bulk disbursement
Send to many recipients in one batch (payroll, agent commissions, refunds). Create a batch, add recipients, then submit.
// 1. Create the batch $batch = api('POST', '/api/v1/disbursements/batches', [ 'title' => 'August 2025 payroll', 'purpose' => 'payroll', ])['data']['batch']; // 2. Add recipients (up to 500 per batch) foreach ($staff as $person) { api('POST', "/api/v1/disbursements/batches/{$batch['id']}/recipients", [ 'recipient_phone' => $person['phone'], 'recipient_name' => $person['name'], 'amount' => $person['salary'], 'reference' => $person['employee_id'], ]); } // 3. Submit (moves batch from 'draft' → 'submitted') api('POST', "/api/v1/disbursements/batches/{$batch['id']}/submit"); // 4. From here — approval + processing happens in the dashboard or via API
Refunds
Refund a successful payment fully or partially. The refund goes back to the payer's mobile-money wallet.
/api/v1/refundscurl -X POST https://yiyopay.com/api/v1/refunds \ -H "Authorization: Bearer $YIYOPAY_KEY" \ -H "Content-Type: application/json" \ -d '{ "payment_ref": "YPCOL2508259A3B1C", "amount": 5000, "reason": "Order cancelled by customer" }'
Omit amount to refund in full. Refunds require merchant approval by default — configure auto-approval at Superadmin → Config if you want them to process immediately.
List transactions
/api/v1/paymentsSupports pagination and filtering:
?status=successful?direction=collection(ordisbursement)?from=2025-08-01&to=2025-08-31?customer_id=42?limit=50&offset=100
curl "https://yiyopay.com/api/v1/payments?status=successful&from=2025-08-01&limit=100" \ -H "Authorization: Bearer $YIYOPAY_KEY"
Customers
Save customer records to link across multiple payments (recurring billing, statements, receipts).
/api/v1/customerscurl -X POST https://yiyopay.com/api/v1/customers \ -H "Authorization: Bearer $YIYOPAY_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Jane Doe", "phone": "0772000000", "email": "jane@example.com", "reference": "STUDENT-2025-042" }'
Then reference the customer on future payments with customer_id. Search with GET /api/v1/customers?q=jane.
Webhooks — real-time updates
Rather than polling, subscribe to events. When a payment settles, we POST the event to your endpoint within seconds.
Registration
Register your endpoint at Developers → Webhooks. Save the signing secret we give you — you'll need it to verify signatures.
Events
| Event | Fires when |
|---|---|
payment.succeeded | Collection reaches successful. |
payment.failed | Collection ends in failed / cancelled / expired. |
disbursement.succeeded | Payout reaches recipient's wallet. |
disbursement.failed | Payout fails at the provider. |
refund.succeeded | Refund completed. |
refund.failed | Refund rejected. |
settlement.completed | Money moved from platform to your bank. |
Payload shape
# Request YiyoPay sends to your endpoint: POST https://your-app.example.com/webhooks/yiyopay Content-Type: application/json X-Yiyopay-Event: payment.succeeded X-Yiyopay-Signature: t=1735048800,v1=abc123def456... { "event": "payment.succeeded", "created_at": "2025-08-26T14:22:47Z", "data": { "payment": { "id": 1234, "yiyopay_ref": "YPCOL2508259A3B1C", "status": "successful", "amount": 10000, "payer_phone": "256772000000", "provider": "mtn" } } }
Verifying the webhook signature
Every webhook is signed with HMAC-SHA256. Always verify before trusting the payload — otherwise anyone who guesses your URL can trigger business logic.
function verify_yiyopay_signature($body_raw, $sig_header, $secret, $tolerance = 300) { if (!preg_match('/t=(\d+),v1=([a-f0-9]+)/', $sig_header, $m)) return false; [$_, $ts, $sig] = $m; // Reject replays — signatures older than 5 minutes are rejected if (abs(time() - (int)$ts) > $tolerance) return false; $expected = hash_hmac('sha256', $ts . '.' . $body_raw, $secret); return hash_equals($expected, $sig); }
const crypto = require('crypto'); function verifyYiyoPaySignature(rawBody, sigHeader, secret, tolerance = 300) { const m = sigHeader.match(/t=(\d+),v1=([a-f0-9]+)/); if (!m) return false; const [, ts, sig] = m; if (Math.abs(Date.now() / 1000 - Number(ts)) > tolerance) return false; const expected = crypto.createHmac('sha256', secret) .update(`${ts}.${rawBody}`).digest('hex'); return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig)); }
import hmac, hashlib, time, re def verify_yiyopay_signature(raw_body: bytes, sig_header: str, secret: str, tolerance: int = 300) -> bool: m = re.match(r"t=(\d+),v1=([a-f0-9]+)", sig_header or "") if not m: return False ts, sig = m.groups() if abs(time.time() - int(ts)) > tolerance: return False payload = f"{ts}.".encode() + raw_body expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, sig)
Complete webhook handler
Drop-in production handler that verifies, dispatches, and idempotently records the event.
<?php // public/webhooks/yiyopay.php — the endpoint you registered with YiyoPay $body = file_get_contents('php://input'); $sig = $_SERVER['HTTP_X_YIYOPAY_SIGNATURE'] ?? ''; $secret = getenv('YIYOPAY_WEBHOOK_SECRET'); // 1. Reject anything without a valid signature if (!verify_yiyopay_signature($body, $sig, $secret)) { http_response_code(401); exit('invalid signature'); } $event = json_decode($body, true); // 2. Idempotency — store the event ID and skip if seen already $pdo = new PDO('mysql:...'); $stmt = $pdo->prepare('INSERT IGNORE INTO webhook_events (event_id) VALUES (?)'); $stmt->execute([$event['data']['payment']['yiyopay_ref'] . '-' . $event['event']]); if ($stmt->rowCount() === 0) { http_response_code(200); exit('already processed'); } // 3. Handle the event switch ($event['event']) { case 'payment.succeeded': $ref = $event['data']['payment']['yiyopay_ref']; $amount = $event['data']['payment']['amount']; // mark the corresponding order/invoice as paid in your DB break; case 'payment.failed': // notify the customer, retry the collection, or mark the order abandoned break; case 'disbursement.succeeded': // mark the payroll line / supplier invoice as paid break; } // 4. Acknowledge — anything but 2xx and we'll retry http_response_code(200); echo 'ok';
const express = require('express'); const crypto = require('crypto'); const app = express(); // Use raw body parser so we can verify signature exactly app.post('/webhooks/yiyopay', express.raw({ type: 'application/json' }), async (req, res) => { const raw = req.body.toString(); const sig = req.headers['x-yiyopay-signature']; if (!verifyYiyoPaySignature(raw, sig, process.env.YIYOPAY_WEBHOOK_SECRET)) { return res.status(401).send('invalid signature'); } const event = JSON.parse(raw); switch (event.event) { case 'payment.succeeded': await markOrderPaid(event.data.payment.yiyopay_ref); break; case 'payment.failed': await handleFailure(event.data.payment); break; } res.status(200).send('ok'); } );
HTTP status codes
| Code | Meaning |
|---|---|
200 | OK — request succeeded. |
201 | Created — resource created. |
400 | Bad request — malformed JSON or invalid parameter. |
401 | Unauthorized — missing or invalid API key. |
402 | Payment required — insufficient platform balance (disbursements). |
404 | Not found — resource doesn't exist under your organisation. |
409 | Conflict — idempotency key already used with a different payload. |
422 | Unprocessable — validation failed (bad phone format, missing field). |
429 | Too many requests — rate limit; retry with back-off. |
5xx | Server error — our issue. Safe to retry with same idempotency key. |
Every error uses the same shape: {"success":false, "message":"...", "code":422, "data":{...}}.
Payment states
| Status | Meaning | Terminal? |
|---|---|---|
created | Intent recorded, provider not yet called. | No |
awaiting_customer | PIN prompt sent, waiting for customer. | No |
processing | Provider is settling. | No |
successful | Money has moved. | Yes |
failed | Provider rejected. See failure_reason. | Yes |
cancelled | Customer or system cancelled. | Yes |
expired | Customer never responded within the window. | Yes |
reversed | Refunded fully. | Yes |
Phone number formats
Any of these are accepted; we normalise to E.164 (2567XXXXXXXX) internally:
0772000000— local with leading zero772000000— local without leading zero+256772000000— E.164 with plus256772000000— E.164 without plus
Provider auto-detection
| Prefix (after 256-) | Provider |
|---|---|
76, 77, 78, 39 | MTN Uganda |
70, 74, 75, 20 | Airtel Uganda |
Pass provider explicitly if you need to override the auto-detection.
Sandbox test numbers
In sandbox mode these numbers give deterministic outcomes:
| Phone | Provider | Outcome |
|---|---|---|
256772000000 | MTN | Instant success |
256772000001 | MTN | Instant failure — insufficient funds |
256772000002 | MTN | Times out after 30s |
256700000000 | Airtel | Instant success |
256700000001 | Airtel | Instant failure — payer rejected |
256700000002 | Airtel | Times out |
| any other | auto | 75% success, 15% fail, 10% expire |
Rate limits
100 requests per minute per API key, sliding window. When exceeded you get HTTP 429 with a Retry-After header (seconds until you can retry).
Response headers on every request tell you your current usage:
X-RateLimit-Limit: 100 X-RateLimit-Remaining: 97 X-RateLimit-Reset: 1735048862 # unix timestamp when window resets
Need higher limits? Contact sales for enterprise pricing.
FAQ
Which currencies do you support?
UGX only, for now. USD, KES, and RWF are on the roadmap.
How long does a collection take?
The PIN prompt reaches the customer within 3–5 seconds. Once they enter their PIN, the callback lands in under 30 seconds on average. If the customer ignores the prompt it expires after 60 seconds (MTN) or 5 minutes (Airtel).
How long until money settles to my bank?
Depends on your settlement frequency (daily or weekly, set in Organisation → Settings). Once triggered, funds land in your bank within 1 business day.
What's the fee?
See the pricing page. Starter is 2.5% + 500 UGX per collection, no monthly minimums. Volume discounts available.
Do you have SDKs for other languages?
PHP SDK is available now (download). Node, Python, and Go SDKs are on the roadmap. In the meantime, every language with an HTTP client works — the examples above cover four of them.
Can I test without signing up?
You need to create a free account (sandbox key generated instantly, no KYC required) but you don't need to verify KYC or link a bank until you go live.
What happens if my server is down when you send a webhook?
We retry with exponential back-off: 30s, 2m, 10m, 1h, 6h. After 5 failed attempts the delivery is marked failed and shown in your Webhooks dashboard where you can retry manually.
Questions the docs don't answer? Email support@yiyopay.com or open a ticket from your dashboard.
© 2026 YiyoPay Ltd · Kampala, Uganda