LWORX API

Accept and send mobile money from your own website or app.

Getting started

One REST API, JSON in and JSON out. Every request is authenticated with your API key.

Base URL

BASE URL
https://www.lworx-pay.com/api/v1

Authentication

Send your live API key in the Authorization header of every request. You will find it under API Keys in your dashboard (KYC approval is required to get one). Keep it secret and only use it from your server.

HEADERS
Authorization: Bearer YOUR_API_KEY
Content-Type: application/json

Rules

Rate limit60 requests per minute per API key (then HTTP 429).
Phone numbersMTN or Airtel, as 0770000000 or 256770000000.
CurrencyUGX. Send amounts as plain numbers, e.g. 5000.
ReferenceYour own ID. It must be unique per account on /collect, /collect-stanbic, /payout and /buy-airtime.
TimestampsUTC.

Responses

Every response has a success flag. Check it first, then read data (success) or error (failure).

SUCCESS · HTTP 200
{
  "success": true,
  "message": "Human readable note (when there is one)",
  "data": {
    "...": "endpoint specific fields"
  }
}
ERROR · HTTP 4xx
{
  "success": false,
  "error": {
    "code": "INVALID_PHONE",
    "message": "Enter a valid 10-digit phone number starting with 07."
  }
}
Build your logic on error.code (stable). The message text is for humans and may change. The HTTP status tells you the class of problem: 400 bad request, 401 bad key, 403 not allowed, 404 not found, 429 too many requests.
A success: true on /collect, /collect-stanbic, /payout and bill endpoints means the request was accepted, not that the money has moved. Wait for the webhook or call /status for the final result.

Transaction statuses

StatusMeaning
pendingAccepted and waiting (for example the customer has not approved the prompt yet).
completedFinished successfully.
failedDid not go through. Any money taken from your wallet is refunded automatically.
cancelledCancelled by the customer or the network.
refundedMoney was returned to your wallet.
pending_approvalPayouts only: waiting for your manual approval (when enabled in API settings).

POST /collect Collect a payment

Send a mobile money prompt to your customer's phone. The customer approves with their PIN and the money is credited to your wallet.

The customer pays amount + 4% (for example amount 5,000 → the customer pays UGX 5,200). Your wallet receives the full amount.
The request returns straight away with status: "pending". The final result arrives on your webhook (payment.success / payment.failed) or by calling /status.

Body parameters (JSON)

NameTypeDescription
amountnumberrequiredAmount in UGX you want to receive. Minimum 500.
phonestringrequiredCustomer's MTN or Airtel number, e.g. 0770000000 or 256770000000.
referencestringrequiredYour own unique order ID. Reusing a reference returns DUPLICATE_REFERENCE.
callback_urlstringoptionalURL we POST the final result to. Separate several URLs with commas.
webhook_secretstringoptionalSecret used to sign the callback (X-LworxPay-Signature header).

Request

POST /collect
curl -X POST "https://www.lworx-pay.com/api/v1/collect" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount":5000,"phone":"0770000000","reference":"ORDER-123","callback_url":"https://yoursite.com/webhook"}'
<?php
$ch = curl_init('https://www.lworx-pay.com/api/v1/collect');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer YOUR_API_KEY',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'amount' => 5000,
        'phone' => '0770000000',
        'reference' => 'ORDER-123',
        'callback_url' => 'https://yoursite.com/webhook',
    ]),
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);

if ($result['success']) {
    $data = $result['data'];
} else {
    // $result['error']['code'] and $result['error']['message']
}
const response = await fetch("https://www.lworx-pay.com/api/v1/collect", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "amount": 5000,
    "phone": "0770000000",
    "reference": "ORDER-123",
    "callback_url": "https://yoursite.com/webhook"
  }),
});
const result = await response.json();

if (result.success) {
  console.log(result.data);
} else {
  console.error(result.error.code, result.error.message);
}
import requests

response = requests.post(
    "https://www.lworx-pay.com/api/v1/collect",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "amount": 5000,
        "phone": "0770000000",
        "reference": "ORDER-123",
        "callback_url": "https://yoursite.com/webhook"
    },
)
result = response.json()

if result["success"]:
    print(result["data"])
else:
    print(result["error"]["code"], result["error"]["message"])

SUCCESS What you get back

SUCCESS · HTTP 200
{
  "success": true,
  "message": "Approve the prompt on your phone to pay UGX 5,200.",
  "data": {
    "transaction_id": "LWAK3F9QZ7A1C",
    "reference": "ORDER-123",
    "status": "pending",
    "amount": 5000,
    "charge_amount": 5200,
    "phone": "0770000000"
  }
}
transaction_idOur unique ID for this transaction.
referenceThe reference you sent.
statusAlways pending at this stage.
amountAmount credited to your wallet once paid.
charge_amountTotal the customer is charged (amount + 4%).
phoneCustomer number in 07XXXXXXXX format.

ERROR What you get when something is wrong

Tap an error to see its exact response.

ERROR · HTTP 4xx
{
  "success": false,
  "error": {
    "code": "MISSING_PARAMS",
    "message": "Required fields: amount, phone, reference"
  }
}
{
  "success": false,
  "error": {
    "code": "DUPLICATE_REFERENCE",
    "message": "This reference has already been used"
  }
}
{
  "success": false,
  "error": {
    "code": "INVALID_PHONE",
    "message": "Enter a valid 10-digit phone number starting with 07."
  }
}
{
  "success": false,
  "error": {
    "code": "INVALID_AMOUNT",
    "message": "Minimum amount is UGX 500"
  }
}
{
  "success": false,
  "error": {
    "code": "PAYMENT_FAILED",
    "message": "Payment processor rejected the request"
  }
}
{
  "success": false,
  "error": {
    "code": "DEPOSIT_DISABLED",
    "message": "Deposits are currently disabled via your API settings. Enable them in your dashboard."
  }
}
{
  "success": false,
  "error": {
    "code": "DEPOSITS_BLOCKED",
    "message": "Deposits are temporarily disabled platform-wide. Please try again later."
  }
}

Authentication, rate limit and account errors (401, 403, 429) can happen on any endpoint — see error codes.

POST /collect-stanbic Collect a payment (Stanbic gateway, no fees)

Send a mobile money prompt to your customer's phone through our Stanbic gateway. Works exactly like /collect, but with no charges at all.

Free: the customer pays exactly the amount you send (no 4% added) and your wallet is credited the full amount.
The request returns straight away with status: "pending". The final result arrives on your webhook (payment.success / payment.failed) or by calling /status. A customer who cancels the prompt is reported as failed.
Collections only. There is no payout on this endpoint — use /payout to send money.

Body parameters (JSON)

NameTypeDescription
amountnumberrequiredAmount in UGX. The customer pays exactly this. Minimum 500.
phonestringrequiredCustomer's MTN or Airtel number, e.g. 0772000000 or 256772000000.
referencestringrequiredYour own unique order ID. Reusing a reference returns DUPLICATE_REFERENCE.
callback_urlstringoptionalURL we POST the final result to. Separate several URLs with commas.
webhook_secretstringoptionalSecret used to sign the callback (X-LworxPay-Signature header).

Request

POST /collect-stanbic
curl -X POST "https://www.lworx-pay.com/api/v1/collect-stanbic" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount":5000,"phone":"0772000000","reference":"ORDER-124","callback_url":"https://yoursite.com/webhook"}'
<?php
$ch = curl_init('https://www.lworx-pay.com/api/v1/collect-stanbic');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer YOUR_API_KEY',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'amount' => 5000,
        'phone' => '0772000000',
        'reference' => 'ORDER-124',
        'callback_url' => 'https://yoursite.com/webhook',
    ]),
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);

if ($result['success']) {
    $data = $result['data'];
} else {
    // $result['error']['code'] and $result['error']['message']
}
const response = await fetch("https://www.lworx-pay.com/api/v1/collect-stanbic", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "amount": 5000,
    "phone": "0772000000",
    "reference": "ORDER-124",
    "callback_url": "https://yoursite.com/webhook"
  }),
});
const result = await response.json();

if (result.success) {
  console.log(result.data);
} else {
  console.error(result.error.code, result.error.message);
}
import requests

response = requests.post(
    "https://www.lworx-pay.com/api/v1/collect-stanbic",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "amount": 5000,
        "phone": "0772000000",
        "reference": "ORDER-124",
        "callback_url": "https://yoursite.com/webhook"
    },
)
result = response.json()

if result["success"]:
    print(result["data"])
else:
    print(result["error"]["code"], result["error"]["message"])

SUCCESS What you get back

SUCCESS · HTTP 200
{
  "success": true,
  "message": "Approve the prompt on your phone to pay UGX 5,000.",
  "data": {
    "transaction_id": "LWSK3F9QZ7A1C",
    "reference": "ORDER-124",
    "status": "pending",
    "amount": 5000,
    "charge_amount": 5000,
    "phone": "0772000000"
  }
}
transaction_idOur unique ID for this transaction.
referenceThe reference you sent.
statusAlways pending at this stage.
amountAmount credited to your wallet once paid.
charge_amountTotal the customer is charged. Always equal to amount (no fees).
phoneCustomer number in 07XXXXXXXX format.

ERROR What you get when something is wrong

Tap an error to see its exact response.

ERROR · HTTP 4xx
{
  "success": false,
  "error": {
    "code": "MISSING_PARAMS",
    "message": "Required fields: amount, phone, reference"
  }
}
{
  "success": false,
  "error": {
    "code": "DUPLICATE_REFERENCE",
    "message": "This reference has already been used"
  }
}
{
  "success": false,
  "error": {
    "code": "INVALID_PHONE",
    "message": "Enter a valid 10-digit phone number starting with 07."
  }
}
{
  "success": false,
  "error": {
    "code": "INVALID_AMOUNT",
    "message": "Minimum amount is UGX 500"
  }
}
{
  "success": false,
  "error": {
    "code": "PAYMENT_FAILED",
    "message": "Payment processor rejected the request"
  }
}
{
  "success": false,
  "error": {
    "code": "DEPOSIT_DISABLED",
    "message": "Deposits are currently disabled via your API settings. Enable them in your dashboard."
  }
}
{
  "success": false,
  "error": {
    "code": "DEPOSITS_BLOCKED",
    "message": "Deposits are temporarily disabled platform-wide. Please try again later."
  }
}
{
  "success": false,
  "error": {
    "code": "GATEWAY_UNAVAILABLE",
    "message": "This payment method is temporarily unavailable. Please try again later."
  }
}
{
  "success": false,
  "error": {
    "code": "GATEWAY_TIMEOUT",
    "message": "We could not confirm this request. Check /status with your reference before trying again."
  }
}

Authentication, rate limit and account errors (401, 403, 429) can happen on any endpoint — see error codes.

POST /payout Send a payout

Send money from your wallet to any MTN or Airtel number. KYC approval is required and new accounts must wait 24 hours before their first payout.

Your wallet is debited amount + fee immediately (see fees). If the payout fails, the full amount and fee are refunded automatically.
Payouts below UGX 1,000 are rejected by the mobile money network and refunded. Alias: /withdraw.

Body parameters (JSON)

NameTypeDescription
amountnumberrequiredAmount in UGX to send. Use 1,000 or more.
phonestringrequiredRecipient MTN or Airtel number.
referencestringrequiredYour own unique payout ID.
callback_urlstringoptionalURL we POST the final result to.

Request

POST /payout
curl -X POST "https://www.lworx-pay.com/api/v1/payout" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount":10000,"phone":"0770000000","reference":"PAYOUT-1","callback_url":"https://yoursite.com/webhook"}'
<?php
$ch = curl_init('https://www.lworx-pay.com/api/v1/payout');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer YOUR_API_KEY',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'amount' => 10000,
        'phone' => '0770000000',
        'reference' => 'PAYOUT-1',
        'callback_url' => 'https://yoursite.com/webhook',
    ]),
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);

if ($result['success']) {
    $data = $result['data'];
} else {
    // $result['error']['code'] and $result['error']['message']
}
const response = await fetch("https://www.lworx-pay.com/api/v1/payout", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "amount": 10000,
    "phone": "0770000000",
    "reference": "PAYOUT-1",
    "callback_url": "https://yoursite.com/webhook"
  }),
});
const result = await response.json();

if (result.success) {
  console.log(result.data);
} else {
  console.error(result.error.code, result.error.message);
}
import requests

response = requests.post(
    "https://www.lworx-pay.com/api/v1/payout",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "amount": 10000,
        "phone": "0770000000",
        "reference": "PAYOUT-1",
        "callback_url": "https://yoursite.com/webhook"
    },
)
result = response.json()

if result["success"]:
    print(result["data"])
else:
    print(result["error"]["code"], result["error"]["message"])

SUCCESS What you get back

SUCCESS · HTTP 200
{
  "success": true,
  "message": "Withdrawal sent. It will arrive shortly.",
  "data": {
    "transaction_id": "LWXM8T2PX4B7D",
    "reference": "PAYOUT-1",
    "status": "pending",
    "amount": 10000,
    "fee": 400
  }
}
{
  "success": true,
  "data": {
    "transaction_id": "LWXM8T2PX4B7D",
    "reference": "PAYOUT-1",
    "status": "pending_approval",
    "amount": 10000,
    "fee": 400
  }
}
transaction_idOur unique ID for this payout.
statuspending, or pending_approval if you turned on manual approval in API settings.
amountAmount sent to the recipient.
feeFee taken from your wallet on top of the amount.

ERROR What you get when something is wrong

Tap an error to see its exact response.

ERROR · HTTP 4xx
{
  "success": false,
  "error": {
    "code": "MISSING_PARAMS",
    "message": "Required fields: amount, phone, reference"
  }
}
{
  "success": false,
  "error": {
    "code": "DUPLICATE_REFERENCE",
    "message": "This reference has already been used"
  }
}
{
  "success": false,
  "error": {
    "code": "INVALID_PHONE",
    "message": "Enter a valid 10-digit phone number starting with 07."
  }
}
{
  "success": false,
  "error": {
    "code": "INVALID_AMOUNT",
    "message": "Minimum withdrawal is UGX 500"
  }
}
{
  "success": false,
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Insufficient balance. You need UGX 10,400 (10,000 + 400 fee) but have UGX 2,000."
  }
}
{
  "success": false,
  "error": {
    "code": "PAYOUT_FAILED",
    "message": "Withdrawal could not be sent. Your wallet has been refunded — please try again later."
  }
}
{
  "success": false,
  "error": {
    "code": "KYC_REQUIRED",
    "message": "KYC verification required. Complete identity verification before withdrawing."
  }
}
{
  "success": false,
  "error": {
    "code": "ACCOUNT_COOLDOWN",
    "message": "New accounts must wait 24 hours before withdrawing. 5 hour(s) remaining."
  }
}
{
  "success": false,
  "error": {
    "code": "WITHDRAWAL_DISABLED",
    "message": "Withdrawals are currently disabled via your API settings."
  }
}
{
  "success": false,
  "error": {
    "code": "WITHDRAWALS_BLOCKED",
    "message": "Withdrawals are temporarily disabled. Please try again later."
  }
}

Authentication, rate limit and account errors (401, 403, 429) can happen on any endpoint — see error codes.

GET /status Check transaction status

Look up any transaction by your own reference or by our transaction_id.

Poll this only as a fallback. Webhooks tell you the moment the status changes.

Query parameters

NameTypeDescription
referencestringrequiredYour reference or our transaction_id (query string). ?transaction_id= also works.

Request

GET /status?reference=ORDER-123
curl "https://www.lworx-pay.com/api/v1/status?reference=ORDER-123" \
  -H "Authorization: Bearer YOUR_API_KEY"
<?php
$ch = curl_init('https://www.lworx-pay.com/api/v1/status?reference=ORDER-123');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer YOUR_API_KEY',
    ],
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);

if ($result['success']) {
    $data = $result['data'];
} else {
    // $result['error']['code'] and $result['error']['message']
}
const response = await fetch("https://www.lworx-pay.com/api/v1/status?reference=ORDER-123", {
  headers: {
    "Authorization": "Bearer YOUR_API_KEY",
  },
});
const result = await response.json();

if (result.success) {
  console.log(result.data);
} else {
  console.error(result.error.code, result.error.message);
}
import requests

response = requests.get(
    "https://www.lworx-pay.com/api/v1/status?reference=ORDER-123",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
)
result = response.json()

if result["success"]:
    print(result["data"])
else:
    print(result["error"]["code"], result["error"]["message"])

SUCCESS What you get back

SUCCESS · HTTP 200
{
  "success": true,
  "data": {
    "transaction_id": "LWAK3F9QZ7A1C",
    "reference": "ORDER-123",
    "status": "completed",
    "amount": 5000,
    "fee": 0,
    "type": "deposit",
    "phone": "0770000000",
    "provider": "mtn",
    "created_at": "2026-10-02 08:15:00",
    "updated_at": "2026-10-02 08:16:12"
  }
}
{
  "success": true,
  "data": {
    "transaction_id": "LWAK3F9QZ7A1C",
    "reference": "ORDER-123",
    "status": "pending",
    "amount": 5000,
    "fee": 0,
    "type": "deposit",
    "phone": "0770000000",
    "provider": "mtn",
    "created_at": "2026-10-02 08:15:00",
    "updated_at": "2026-10-02 08:15:00"
  }
}
statuspending, completed, failed, cancelled or refunded. See statuses.
typedeposit, withdrawal, airtime, bundle, electricity, water, tv.
providerCustomer network: mtn or airtel.
feeFee charged to your wallet (0 for collections).
created_at / updated_atUTC time, format YYYY-MM-DD HH:MM:SS.

ERROR What you get when something is wrong

Tap an error to see its exact response.

ERROR · HTTP 4xx
{
  "success": false,
  "error": {
    "code": "MISSING_PARAMS",
    "message": "Provide ?reference="
  }
}
{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "Transaction not found"
  }
}

Authentication, rate limit and account errors (401, 403, 429) can happen on any endpoint — see error codes.

GET /balance Get wallet balance

Returns the current balance of your LWORXPAY wallet.

Request

GET /balance
curl "https://www.lworx-pay.com/api/v1/balance" \
  -H "Authorization: Bearer YOUR_API_KEY"
<?php
$ch = curl_init('https://www.lworx-pay.com/api/v1/balance');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer YOUR_API_KEY',
    ],
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);

if ($result['success']) {
    $data = $result['data'];
} else {
    // $result['error']['code'] and $result['error']['message']
}
const response = await fetch("https://www.lworx-pay.com/api/v1/balance", {
  headers: {
    "Authorization": "Bearer YOUR_API_KEY",
  },
});
const result = await response.json();

if (result.success) {
  console.log(result.data);
} else {
  console.error(result.error.code, result.error.message);
}
import requests

response = requests.get(
    "https://www.lworx-pay.com/api/v1/balance",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
)
result = response.json()

if result["success"]:
    print(result["data"])
else:
    print(result["error"]["code"], result["error"]["message"])

SUCCESS What you get back

SUCCESS · HTTP 200
{
  "success": true,
  "data": {
    "balance": 125000,
    "currency": "UGX",
    "merchant_id": "LWORX123456"
  }
}
balanceWallet balance in UGX.
currencyAlways UGX.
merchant_idYour LWORXPAY merchant ID.

ERROR What you get when something is wrong

This endpoint has no endpoint-specific errors.

Authentication, rate limit and account errors (401, 403, 429) can happen on any endpoint — see error codes.

POST /validate-number Validate a phone number

Get the name a mobile money number is registered under — useful to confirm the recipient before a payout.

The name comes from the mobile network exactly as registered, in UPPERCASE (first name then last name), for example ALICE NAKATO. If the network has no name on record, customer_name is "Unknown". Alias: /verify-number.

Body parameters (JSON)

NameTypeDescription
phonestringrequiredMTN or Airtel number to check.

Request

POST /validate-number
curl -X POST "https://www.lworx-pay.com/api/v1/validate-number" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"phone":"0770000000"}'
<?php
$ch = curl_init('https://www.lworx-pay.com/api/v1/validate-number');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer YOUR_API_KEY',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'phone' => '0770000000',
    ]),
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);

if ($result['success']) {
    $data = $result['data'];
} else {
    // $result['error']['code'] and $result['error']['message']
}
const response = await fetch("https://www.lworx-pay.com/api/v1/validate-number", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "phone": "0770000000"
  }),
});
const result = await response.json();

if (result.success) {
  console.log(result.data);
} else {
  console.error(result.error.code, result.error.message);
}
import requests

response = requests.post(
    "https://www.lworx-pay.com/api/v1/validate-number",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "phone": "0770000000"
    },
)
result = response.json()

if result["success"]:
    print(result["data"])
else:
    print(result["error"]["code"], result["error"]["message"])

SUCCESS What you get back

SUCCESS · HTTP 200
{
  "success": true,
  "data": {
    "customer_name": "ALICE NAKATO",
    "phone": "0770000000"
  }
}
customer_nameRegistered name, UPPERCASE, or "Unknown".
phoneThe number exactly as you sent it.

ERROR What you get when something is wrong

Tap an error to see its exact response.

ERROR · HTTP 4xx
{
  "success": false,
  "error": {
    "code": "MISSING_PARAMS",
    "message": "Required field: phone"
  }
}
{
  "success": false,
  "error": {
    "code": "VERIFICATION_FAILED",
    "message": "Could not verify this number"
  }
}

Authentication, rate limit and account errors (401, 403, 429) can happen on any endpoint — see error codes.

POST /buy-airtime Buy airtime

Top up airtime on any MTN or Airtel number from your wallet. No fee. KYC approval required.

Amount range: UGX 500 – 5,000,000. If the purchase fails, your wallet is refunded automatically.

Body parameters (JSON)

NameTypeDescription
amountnumberrequiredAirtime amount in UGX (500 – 5,000,000).
phonestringrequiredNumber to top up.
referencestringrequiredYour own unique ID.
callback_urlstringoptionalURL we POST the final result to.

Request

POST /buy-airtime
curl -X POST "https://www.lworx-pay.com/api/v1/buy-airtime" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"amount":1000,"phone":"0770000000","reference":"AIR-1"}'
<?php
$ch = curl_init('https://www.lworx-pay.com/api/v1/buy-airtime');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer YOUR_API_KEY',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'amount' => 1000,
        'phone' => '0770000000',
        'reference' => 'AIR-1',
    ]),
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);

if ($result['success']) {
    $data = $result['data'];
} else {
    // $result['error']['code'] and $result['error']['message']
}
const response = await fetch("https://www.lworx-pay.com/api/v1/buy-airtime", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "amount": 1000,
    "phone": "0770000000",
    "reference": "AIR-1"
  }),
});
const result = await response.json();

if (result.success) {
  console.log(result.data);
} else {
  console.error(result.error.code, result.error.message);
}
import requests

response = requests.post(
    "https://www.lworx-pay.com/api/v1/buy-airtime",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "amount": 1000,
        "phone": "0770000000",
        "reference": "AIR-1"
    },
)
result = response.json()

if result["success"]:
    print(result["data"])
else:
    print(result["error"]["code"], result["error"]["message"])

SUCCESS What you get back

SUCCESS · HTTP 200
{
  "success": true,
  "message": "Airtime purchase submitted.",
  "data": {
    "transaction_id": "LWXP2Q7R9S1TA",
    "reference": "AIR-1",
    "status": "completed",
    "amount": 1000,
    "fee": 0
  }
}
{
  "success": true,
  "message": "Airtime purchase submitted.",
  "data": {
    "transaction_id": "LWXP2Q7R9S1TA",
    "reference": "AIR-1",
    "status": "pending",
    "amount": 1000,
    "fee": 0
  }
}
statuscompleted when delivered instantly, otherwise pending (final result by webhook).
amountAirtime value in UGX.
feeAlways 0 for airtime.

ERROR What you get when something is wrong

Tap an error to see its exact response.

ERROR · HTTP 4xx
{
  "success": false,
  "error": {
    "code": "MISSING_PARAMS",
    "message": "Required fields: amount, phone, reference"
  }
}
{
  "success": false,
  "error": {
    "code": "DUPLICATE_REFERENCE",
    "message": "This reference has already been used"
  }
}
{
  "success": false,
  "error": {
    "code": "INVALID_PHONE",
    "message": "Enter a valid 10-digit phone number starting with 07."
  }
}
{
  "success": false,
  "error": {
    "code": "KYC_REQUIRED",
    "message": "Approved KYC is required for bills and airtime."
  }
}
{
  "success": false,
  "error": {
    "code": "LIMIT",
    "message": "Maximum is UGX 5,000,000 per transaction."
  }
}
{
  "success": false,
  "error": {
    "code": "INVALID_AMOUNT",
    "message": "Minimum airtime is UGX 500"
  }
}
{
  "success": false,
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Insufficient balance. You need UGX 1,000 but have UGX 200."
  }
}
{
  "success": false,
  "error": {
    "code": "PURCHASE_FAILED",
    "message": "Purchase failed. Your wallet has been refunded."
  }
}

Authentication, rate limit and account errors (401, 403, 429) can happen on any endpoint — see error codes.

GET /products List data & voice bundles

Returns the bundle catalogue. Use a bundle ID from this list when calling /buy-data.

The catalogue is passed through from our network partner, so it can include extra fields and its layout may grow over time — read the fields you need and ignore the rest. Alias: /list-products.

Request

GET /products
curl "https://www.lworx-pay.com/api/v1/products" \
  -H "Authorization: Bearer YOUR_API_KEY"
<?php
$ch = curl_init('https://www.lworx-pay.com/api/v1/products');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer YOUR_API_KEY',
    ],
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);

if ($result['success']) {
    $data = $result['data'];
} else {
    // $result['error']['code'] and $result['error']['message']
}
const response = await fetch("https://www.lworx-pay.com/api/v1/products", {
  headers: {
    "Authorization": "Bearer YOUR_API_KEY",
  },
});
const result = await response.json();

if (result.success) {
  console.log(result.data);
} else {
  console.error(result.error.code, result.error.message);
}
import requests

response = requests.get(
    "https://www.lworx-pay.com/api/v1/products",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
)
result = response.json()

if result["success"]:
    print(result["data"])
else:
    print(result["error"]["code"], result["error"]["message"])

SUCCESS What you get back

SUCCESS · HTTP 200
{
  "success": true,
  "data": {
    "country": "UG",
    "currency": "UGX",
    "networks": [
      {
        "code": "MTN",
        "airtime": true,
        "bundles": true,
        "min_airtime_amount": 500
      },
      {
        "code": "AIRTEL",
        "airtime": true,
        "bundles": true,
        "min_airtime_amount": 500
      }
    ]
  }
}
networks[].codeMTN or AIRTEL.
bundle product_id / bundle_idThe ID you pass as bundle_id in /buy-data.
priceBundle price in UGX (taken from your wallet).

ERROR What you get when something is wrong

This endpoint has no endpoint-specific errors.

Authentication, rate limit and account errors (401, 403, 429) can happen on any endpoint — see error codes.

POST /buy-data Buy a data or voice bundle

Buy a bundle from your wallet. The price is looked up from the catalogue automatically. No fee. KYC approval required.

Alias: /buy-voice.

Body parameters (JSON)

NameTypeDescription
bundle_idstringrequiredBundle ID taken from /products.
phonestringrequiredNumber that receives the bundle.
referencestringrequiredYour own unique ID.
callback_urlstringoptionalURL we POST the final result to.

Request

POST /buy-data
curl -X POST "https://www.lworx-pay.com/api/v1/buy-data" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"bundle_id":"ID_FROM_PRODUCTS","phone":"0770000000","reference":"DATA-1"}'
<?php
$ch = curl_init('https://www.lworx-pay.com/api/v1/buy-data');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer YOUR_API_KEY',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'bundle_id' => 'ID_FROM_PRODUCTS',
        'phone' => '0770000000',
        'reference' => 'DATA-1',
    ]),
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);

if ($result['success']) {
    $data = $result['data'];
} else {
    // $result['error']['code'] and $result['error']['message']
}
const response = await fetch("https://www.lworx-pay.com/api/v1/buy-data", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "bundle_id": "ID_FROM_PRODUCTS",
    "phone": "0770000000",
    "reference": "DATA-1"
  }),
});
const result = await response.json();

if (result.success) {
  console.log(result.data);
} else {
  console.error(result.error.code, result.error.message);
}
import requests

response = requests.post(
    "https://www.lworx-pay.com/api/v1/buy-data",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "bundle_id": "ID_FROM_PRODUCTS",
        "phone": "0770000000",
        "reference": "DATA-1"
    },
)
result = response.json()

if result["success"]:
    print(result["data"])
else:
    print(result["error"]["code"], result["error"]["message"])

SUCCESS What you get back

SUCCESS · HTTP 200
{
  "success": true,
  "message": "Bundle purchase submitted.",
  "data": {
    "transaction_id": "LWXD4F6G8H2JK",
    "reference": "DATA-1",
    "status": "completed",
    "amount": 5000,
    "fee": 0
  }
}
statuscompleted or pending.
amountBundle price taken from your wallet.

ERROR What you get when something is wrong

Tap an error to see its exact response.

ERROR · HTTP 4xx
{
  "success": false,
  "error": {
    "code": "MISSING_PARAMS",
    "message": "Required fields: phone, bundle_id, reference"
  }
}
{
  "success": false,
  "error": {
    "code": "INVALID_BUNDLE",
    "message": "Unknown bundle_id"
  }
}
{
  "success": false,
  "error": {
    "code": "INVALID_PHONE",
    "message": "Enter a valid 10-digit phone number starting with 07."
  }
}
{
  "success": false,
  "error": {
    "code": "KYC_REQUIRED",
    "message": "Approved KYC is required for bills and airtime."
  }
}
{
  "success": false,
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Insufficient balance. You need UGX 5,000 but have UGX 1,200."
  }
}
{
  "success": false,
  "error": {
    "code": "PURCHASE_FAILED",
    "message": "Purchase failed. Your wallet has been refunded."
  }
}

Authentication, rate limit and account errors (401, 403, 429) can happen on any endpoint — see error codes.

POST /buy-electricity Pay UMEME electricity (Yaka)

Pay a prepaid UMEME meter from your wallet. Fee: UGX 500 per payment. KYC approval required.

Your wallet is debited amount + 500. If the payment fails, both are refunded automatically.

Body parameters (JSON)

NameTypeDescription
meter_numberstringrequiredUMEME prepaid meter number.
amountnumberrequiredAmount in UGX (up to 5,000,000).
phonestringrequiredCustomer phone number.
referencestringrequiredYour own unique ID.
customer_namestringoptionalMeter owner name, if you have it.

Request

POST /buy-electricity
curl -X POST "https://www.lworx-pay.com/api/v1/buy-electricity" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"meter_number":"04xxxxxxxxx","amount":20000,"phone":"0770000000","reference":"BILL-1"}'
<?php
$ch = curl_init('https://www.lworx-pay.com/api/v1/buy-electricity');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer YOUR_API_KEY',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'meter_number' => '04xxxxxxxxx',
        'amount' => 20000,
        'phone' => '0770000000',
        'reference' => 'BILL-1',
    ]),
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);

if ($result['success']) {
    $data = $result['data'];
} else {
    // $result['error']['code'] and $result['error']['message']
}
const response = await fetch("https://www.lworx-pay.com/api/v1/buy-electricity", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "meter_number": "04xxxxxxxxx",
    "amount": 20000,
    "phone": "0770000000",
    "reference": "BILL-1"
  }),
});
const result = await response.json();

if (result.success) {
  console.log(result.data);
} else {
  console.error(result.error.code, result.error.message);
}
import requests

response = requests.post(
    "https://www.lworx-pay.com/api/v1/buy-electricity",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "meter_number": "04xxxxxxxxx",
        "amount": 20000,
        "phone": "0770000000",
        "reference": "BILL-1"
    },
)
result = response.json()

if result["success"]:
    print(result["data"])
else:
    print(result["error"]["code"], result["error"]["message"])

SUCCESS What you get back

SUCCESS · HTTP 200
{
  "success": true,
  "message": "Electricity purchase submitted.",
  "data": {
    "transaction_id": "LWXB3C5D7E9FG",
    "reference": "BILL-1",
    "status": "pending",
    "amount": 20000,
    "fee": 500
  }
}
statuspending or completed. Final result by webhook (bill.success / bill.failed).
amountAmount paid to the meter.
feeAlways 500 for bill payments.

ERROR What you get when something is wrong

Tap an error to see its exact response.

ERROR · HTTP 4xx
{
  "success": false,
  "error": {
    "code": "MISSING_PARAMS",
    "message": "Required fields: meter_number, amount, phone, reference"
  }
}
{
  "success": false,
  "error": {
    "code": "KYC_REQUIRED",
    "message": "Approved KYC is required for bills and airtime."
  }
}
{
  "success": false,
  "error": {
    "code": "LIMIT",
    "message": "Maximum is UGX 5,000,000 per transaction."
  }
}
{
  "success": false,
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Insufficient balance. You need UGX 20,500 but have UGX 3,000."
  }
}
{
  "success": false,
  "error": {
    "code": "PURCHASE_FAILED",
    "message": "Purchase failed. Your wallet has been refunded."
  }
}

Authentication, rate limit and account errors (401, 403, 429) can happen on any endpoint — see error codes.

POST /buy-water Pay NWSC water bill

Pay a National Water & Sewerage Corporation account from your wallet. Fee: UGX 500 per payment. KYC approval required.

Same response and errors as /buy-electricity. Your wallet is debited amount + 500.

Body parameters (JSON)

NameTypeDescription
meter_numberstringrequiredNWSC customer / meter number.
areastringrequiredNWSC service area of the customer.
amountnumberrequiredAmount in UGX.
phonestringrequiredCustomer phone number.
referencestringrequiredYour own unique ID.
customer_namestringoptionalAccount holder name, if you have it.

Request

POST /buy-water
curl -X POST "https://www.lworx-pay.com/api/v1/buy-water" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"meter_number":"1234567","area":"KAMPALA","amount":30000,"phone":"0770000000","reference":"WATER-1"}'
<?php
$ch = curl_init('https://www.lworx-pay.com/api/v1/buy-water');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer YOUR_API_KEY',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'meter_number' => '1234567',
        'area' => 'KAMPALA',
        'amount' => 30000,
        'phone' => '0770000000',
        'reference' => 'WATER-1',
    ]),
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);

if ($result['success']) {
    $data = $result['data'];
} else {
    // $result['error']['code'] and $result['error']['message']
}
const response = await fetch("https://www.lworx-pay.com/api/v1/buy-water", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "meter_number": "1234567",
    "area": "KAMPALA",
    "amount": 30000,
    "phone": "0770000000",
    "reference": "WATER-1"
  }),
});
const result = await response.json();

if (result.success) {
  console.log(result.data);
} else {
  console.error(result.error.code, result.error.message);
}
import requests

response = requests.post(
    "https://www.lworx-pay.com/api/v1/buy-water",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "meter_number": "1234567",
        "area": "KAMPALA",
        "amount": 30000,
        "phone": "0770000000",
        "reference": "WATER-1"
    },
)
result = response.json()

if result["success"]:
    print(result["data"])
else:
    print(result["error"]["code"], result["error"]["message"])

SUCCESS What you get back

SUCCESS · HTTP 200
{
  "success": true,
  "message": "Water purchase submitted.",
  "data": {
    "transaction_id": "LWXW5X7Y9Z1AB",
    "reference": "WATER-1",
    "status": "pending",
    "amount": 30000,
    "fee": 500
  }
}
statuspending or completed.
feeAlways 500.

ERROR What you get when something is wrong

Tap an error to see its exact response.

ERROR · HTTP 4xx
{
  "success": false,
  "error": {
    "code": "MISSING_PARAMS",
    "message": "Required fields: meter_number, amount, phone, reference"
  }
}
{
  "success": false,
  "error": {
    "code": "KYC_REQUIRED",
    "message": "Approved KYC is required for bills and airtime."
  }
}
{
  "success": false,
  "error": {
    "code": "LIMIT",
    "message": "Maximum is UGX 5,000,000 per transaction."
  }
}
{
  "success": false,
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Insufficient balance. You need UGX 30,500 but have UGX 3,000."
  }
}
{
  "success": false,
  "error": {
    "code": "PURCHASE_FAILED",
    "message": "Purchase failed. Your wallet has been refunded."
  }
}

Authentication, rate limit and account errors (401, 403, 429) can happen on any endpoint — see error codes.

POST /buy-tv Pay DStv / GOtv subscription

Renew a DStv or GOtv subscription from your wallet. Fee: UGX 500 per payment. KYC approval required.

Same response and errors as /buy-electricity. Your wallet is debited amount + 500.

Body parameters (JSON)

NameTypeDescription
providerstringrequiredDSTV or GOTV.
bouquet_codestringrequiredPackage code, e.g. GOTVPLS.
meter_numberstringrequiredSmartcard / IUC number.
amountnumberrequiredAmount in UGX.
phonestringrequiredCustomer phone number.
referencestringrequiredYour own unique ID.

Request

POST /buy-tv
curl -X POST "https://www.lworx-pay.com/api/v1/buy-tv" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"provider":"GOTV","bouquet_code":"GOTVPLS","meter_number":"7012345678","amount":38000,"phone":"0770000000","reference":"TV-1"}'
<?php
$ch = curl_init('https://www.lworx-pay.com/api/v1/buy-tv');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => 'POST',
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer YOUR_API_KEY',
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS     => json_encode([
        'provider' => 'GOTV',
        'bouquet_code' => 'GOTVPLS',
        'meter_number' => '7012345678',
        'amount' => 38000,
        'phone' => '0770000000',
        'reference' => 'TV-1',
    ]),
]);
$result = json_decode(curl_exec($ch), true);
curl_close($ch);

if ($result['success']) {
    $data = $result['data'];
} else {
    // $result['error']['code'] and $result['error']['message']
}
const response = await fetch("https://www.lworx-pay.com/api/v1/buy-tv", {
  method: "POST",
  headers: {
    "Authorization": "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "provider": "GOTV",
    "bouquet_code": "GOTVPLS",
    "meter_number": "7012345678",
    "amount": 38000,
    "phone": "0770000000",
    "reference": "TV-1"
  }),
});
const result = await response.json();

if (result.success) {
  console.log(result.data);
} else {
  console.error(result.error.code, result.error.message);
}
import requests

response = requests.post(
    "https://www.lworx-pay.com/api/v1/buy-tv",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "provider": "GOTV",
        "bouquet_code": "GOTVPLS",
        "meter_number": "7012345678",
        "amount": 38000,
        "phone": "0770000000",
        "reference": "TV-1"
    },
)
result = response.json()

if result["success"]:
    print(result["data"])
else:
    print(result["error"]["code"], result["error"]["message"])

SUCCESS What you get back

SUCCESS · HTTP 200
{
  "success": true,
  "message": "Tv purchase submitted.",
  "data": {
    "transaction_id": "LWXT6U8V1W3XY",
    "reference": "TV-1",
    "status": "pending",
    "amount": 38000,
    "fee": 500
  }
}
statuspending or completed.
feeAlways 500.

ERROR What you get when something is wrong

Tap an error to see its exact response.

ERROR · HTTP 4xx
{
  "success": false,
  "error": {
    "code": "MISSING_PARAMS",
    "message": "Required fields: meter_number, amount, phone, reference"
  }
}
{
  "success": false,
  "error": {
    "code": "KYC_REQUIRED",
    "message": "Approved KYC is required for bills and airtime."
  }
}
{
  "success": false,
  "error": {
    "code": "LIMIT",
    "message": "Maximum is UGX 5,000,000 per transaction."
  }
}
{
  "success": false,
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "Insufficient balance. You need UGX 38,500 but have UGX 3,000."
  }
}
{
  "success": false,
  "error": {
    "code": "PURCHASE_FAILED",
    "message": "Purchase failed. Your wallet has been refunded."
  }
}

Authentication, rate limit and account errors (401, 403, 429) can happen on any endpoint — see error codes.

Webhooks

We POST a JSON event to your callback_url (and to webhooks saved in your dashboard) as soon as a transaction changes status.

Events

payment.success · payment.failedResult of a /collect or /collect-stanbic request.
payout.success · payout.failedResult of a /payout request.
bill.success · bill.failedResult of airtime, data, electricity, water or TV purchases.
*.pendingSent if a transaction is still processing (payment.pending, payout.pending, bill.pending).

Payload

payment.success
{
  "event": "payment.success",
  "timestamp": "2026-10-02T08:16:12+00:00",
  "data": {
    "transaction_id": "LWAK3F9QZ7A1C",
    "reference": "ORDER-123",
    "merchant_reference": "ORDER-123",
    "amount": 5000,
    "status": "completed",
    "type": "deposit",
    "phone": "0770000000",
    "provider": "mtn",
    "created_at": "2026-10-02 08:15:00"
  }
}
payment.failed
{
  "event": "payment.failed",
  "timestamp": "2026-10-02T08:17:40+00:00",
  "data": {
    "transaction_id": "LWAK3F9QZ7A1C",
    "reference": "ORDER-123",
    "merchant_reference": "ORDER-123",
    "amount": 5000,
    "status": "failed",
    "type": "deposit",
    "phone": "0770000000",
    "provider": "mtn",
    "created_at": "2026-10-02 08:15:00"
  }
}

Headers we send

X-LworxPay-EventThe event name, e.g. payment.success.
X-LworxPay-SignatureHMAC-SHA256 of the raw request body using your secret (sent when a secret is set).
User-AgentLworxPay-Webhook/1.0

Verify and reply

Recompute the signature from the raw body and compare. Reply with any 2xx status. If your server does not answer with success we retry up to 3 times, so make your handler safe to run twice (use transaction_id to skip duplicates).

VERIFY SIGNATURE
<?php
$raw = file_get_contents('php://input');
$expected = hash_hmac('sha256', $raw, 'YOUR_WEBHOOK_SECRET');

if (!hash_equals($expected, $_SERVER['HTTP_X_LWORXPAY_SIGNATURE'] ?? '')) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);
if ($event['event'] === 'payment.success') {
    // mark order $event['data']['reference'] as paid
}
http_response_code(200);
const crypto = require("crypto");

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const expected = crypto.createHmac("sha256", process.env.WEBHOOK_SECRET).update(req.body).digest("hex");
  const sent = req.get("X-LworxPay-Signature") || "";

  if (sent.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sent), Buffer.from(expected))) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body);
  if (event.event === "payment.success") {
    // mark order event.data.reference as paid
  }
  res.sendStatus(200);
});

Fees & limits

ActionFee
Collect (/collect)4% added on top — paid by the customer, not taken from you.
Collect via Stanbic (/collect-stanbic)Free. No fee added for the customer and nothing taken from you.
Payout (/payout)UGX 500 for amounts up to 5,000 · 10% for 5,001 – 9,999 · 4% for 10,000 and above. Taken from your wallet on top of the amount. A custom fee may apply to your account.
Airtime & data bundlesFree.
Electricity, water, TVUGX 500 per payment.
Limit per bill / airtime purchaseUGX 5,000,000.

Error codes

All errors use the same shape and the HTTP status shown below.

HTTPCodeMeaning
401AUTHENTICATION_FAILEDAPI key missing, wrong or inactive.
403ACCOUNT_SUSPENDEDYour account is suspended. Contact support.
403IP_NOT_ALLOWEDYour server IP is not on your IP whitelist.
429RATE_LIMITEDMore than 60 requests in one minute.
400MISSING_PARAMSA required field is missing.
400DUPLICATE_REFERENCEYou already used this reference.
400INVALID_PHONEPhone number is not a valid Ugandan number.
400INVALID_AMOUNTAmount is below the minimum.
400LIMITAmount is above the UGX 5,000,000 limit.
400INSUFFICIENT_BALANCEWallet cannot cover the amount plus fee.
400INVALID_BUNDLEbundle_id is not in the catalogue.
400VERIFICATION_FAILEDThe network could not verify the number.
400PAYMENT_FAILED / PAYOUT_FAILED / PURCHASE_FAILEDThe mobile money network rejected the request. Wallet changes are reversed automatically.
403KYC_REQUIREDComplete KYC verification first.
403ACCOUNT_COOLDOWNNew accounts wait 24 hours before payouts.
403DEPOSIT_DISABLED / WITHDRAWAL_DISABLEDSwitched off in your API settings or on your account.
403DEPOSITS_BLOCKED / WITHDRAWALS_BLOCKEDTemporarily paused platform-wide.
404NOT_FOUNDUnknown transaction or endpoint.
503GATEWAY_UNAVAILABLE/collect-stanbic only: the gateway is temporarily unavailable. Safe to retry.
504GATEWAY_TIMEOUT/collect-stanbic only: no answer from the gateway. The prompt may have been sent, so check /status with your reference before retrying.

Need help integrating? Email supports@lworx-pay.com.