Developer documentation

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.com

All API paths start with /api/v1/. Same base URL for sandbox and live — the environment is inferred from your API key prefix.

Simple mental model:

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.

EnvironmentPrefixBehaviour
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.
Secrets are shown once.

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

POST /api/v1/payments

Request body

FieldTypeNotes
amount *numberAmount in shillings (integer). Min 500 UGX.
payer_phone *stringLocal (07XXXXXXXX) or E.164 (2567XXXXXXXX). Provider auto-detected.
currencystringDefaults to UGX.
payer_namestringShown on receipts and your dashboard.
purposestringFree-text description.
customer_idintegerLink to an existing customer record.
referencestringYour internal reference (invoice number, order ID, etc.).
providerstringForce mtn or airtel — otherwise auto-detected from the phone prefix.
callback_urlstringOverride 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);
}
Idempotency keys are valid for 24 hours.

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.

GET /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');
}
Webhooks are strongly preferred.

Polling wastes API quota and adds latency. Use polling as a fallback, not the main flow.

Sending money — single disbursement

POST /api/v1/disbursements

Pay 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.

POST /api/v1/refunds
curl -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

GET /api/v1/payments

Supports pagination and filtering:

  • ?status=successful
  • ?direction=collection (or disbursement)
  • ?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).

POST /api/v1/customers
curl -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

EventFires when
payment.succeededCollection reaches successful.
payment.failedCollection ends in failed / cancelled / expired.
disbursement.succeededPayout reaches recipient's wallet.
disbursement.failedPayout fails at the provider.
refund.succeededRefund completed.
refund.failedRefund rejected.
settlement.completedMoney 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"
    }
  }
}
Retry policy: we retry failed deliveries with exponential back-off — 30s, 2m, 10m, 1h, 6h. Return any 2xx status to acknowledge. Failed deliveries appear on your Webhooks page.

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

CodeMeaning
200OK — request succeeded.
201Created — resource created.
400Bad request — malformed JSON or invalid parameter.
401Unauthorized — missing or invalid API key.
402Payment required — insufficient platform balance (disbursements).
404Not found — resource doesn't exist under your organisation.
409Conflict — idempotency key already used with a different payload.
422Unprocessable — validation failed (bad phone format, missing field).
429Too many requests — rate limit; retry with back-off.
5xxServer error — our issue. Safe to retry with same idempotency key.

Every error uses the same shape: {"success":false, "message":"...", "code":422, "data":{...}}.

Payment states

StatusMeaningTerminal?
createdIntent recorded, provider not yet called.No
awaiting_customerPIN prompt sent, waiting for customer.No
processingProvider is settling.No
successfulMoney has moved.Yes
failedProvider rejected. See failure_reason.Yes
cancelledCustomer or system cancelled.Yes
expiredCustomer never responded within the window.Yes
reversedRefunded fully.Yes

Phone number formats

Any of these are accepted; we normalise to E.164 (2567XXXXXXXX) internally:

  • 0772000000 — local with leading zero
  • 772000000 — local without leading zero
  • +256772000000 — E.164 with plus
  • 256772000000 — E.164 without plus

Provider auto-detection

Prefix (after 256-)Provider
76, 77, 78, 39MTN Uganda
70, 74, 75, 20Airtel Uganda

Pass provider explicitly if you need to override the auto-detection.

Sandbox test numbers

In sandbox mode these numbers give deterministic outcomes:

PhoneProviderOutcome
256772000000MTNInstant success
256772000001MTNInstant failure — insufficient funds
256772000002MTNTimes out after 30s
256700000000AirtelInstant success
256700000001AirtelInstant failure — payer rejected
256700000002AirtelTimes out
any otherauto75% 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