Unified Payment Gateway API

Unified Payment Gateway API

Collect money from Airtel Money and TNM Mpamba customers in Malawi, and pay money out to them, through one API and one wallet.

One walletCollections from both networks land in a single MWK balance. Payouts to either network come out of it.
No network fieldSend the phone number. The gateway reads the prefix: 099… is Airtel, 088… is TNM.
One shapeStatus lookups and webhooks return the same transaction object for both networks.
Every endpoint on this page is relative to this address. HTTPS only.

Authentication

Every request carries your API key as a Bearer token:

Header
Authorization: Bearer <YOUR_API_KEY>
  • Your key is shown once, when your account is created or when the key is regenerated. Store it in a server-side secret store.
  • You can regenerate the key and set your webhook (callback) URL yourself, in the management portal under Integration. The old key stops working as soon as a new one is issued.
  • Never put the key in a mobile app or browser code. Call the gateway from your own server.
ResponseMeaning
401 MISSING_API_KEYNo Authorization: Bearer … header was sent.
401 INVALID_OR_INACTIVE_API_KEYThe key is wrong, was replaced, or your account is suspended.

Request rules

Send JSON with Content-Type: application/json. All amounts are Malawi Kwacha (MWK).

FieldRule
msisdnThe customer's phone number. Any of 0991234567, 991234567, 265991234567 or +265 99 123 4567. Prefix 2659… routes to Airtel Money, 2658… to TNM Mpamba; anything else is refused with 422 UNRECOGNIZED_MSISDN_PREFIX.
amountA positive number with at most 2 decimal places, as a number or a string (1500 or "1500.00"). The networks also set a minimum and maximum per transaction; amounts outside them get 422 AMOUNT_OUTSIDE_PROVIDER_LIMITS.
tenant_referenceYour own ID for the transaction, up to 100 characters. It must be unique across your account: reusing one returns 409 DUPLICATE_TENANT_REFERENCE. This is what makes retries safe. If a request times out, look the reference up instead of sending a new one.
narrationOptional, up to 200 characters. On payouts it becomes the reference the recipient sees (Airtel shows the first 25 letters and digits).

Money in responses is always a string with two decimals, such as "1500.00". Parse it as a decimal, never as a float.

Transaction status

StatusFinal?Meaning
pendingNoSent to the network; the outcome is not known yet. Wait for the webhook, or look the transaction up.
successYesCollection: the customer paid and your wallet was credited. Payout: the recipient was paid.
failedYesNothing moved, or held money was returned to your wallet. failure_reason says why.
expiredUsuallyCollections only: the customer did not approve the prompt in time. If they approve late, it still becomes success and you get a webhook.
reversedYesA completed transaction was reversed by the network or the operator.

Collect a payment (STK push)

POST/v1/collections/stkpush

Sends a payment prompt to the customer's phone. They approve it with their PIN. The response only means the prompt was sent: the result arrives later by webhook.

FieldDescription
msisdnrequiredCustomer's phone number.
amountrequiredAmount to collect.
tenant_referencerequiredYour unique ID.
narrationoptionalDescription of the payment.
Request
curl -X POST {{BASE}}/v1/collections/stkpush \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "msisdn": "0991234567",
    "amount": 1000,
    "tenant_reference": "ORDER-10045",
    "narration": "Order 10045"
  }'
202 Accepted
{
  "gateway_reference": "MLIMIP261014K3F9Q2X7A1",
  "tenant_reference": "ORDER-10045",
  "provider": "airtel",
  "status": "pending",
  "amount": "1000.00",
  "fee_amount": "0.00",
  "provider_charge_amount": "0.00",
  "customer_amount": "1000.00",
  "wallet_amount": "1000.00",
  "message": "STK push sent to customer for approval"
}
  • customer_amount is what the customer is asked to approve. It is higher than amount only when your account passes the network charge on to the payer (see charges).
  • wallet_amount is what your wallet will be credited on success.
  • If the network refuses the request outright, you get 502 PROVIDER_REJECTED_REQUEST with "status": "failed", and nothing was charged.

Quote a payout

POST/v1/disbursements/quote

Tells you what a payout would cost and whether your balance covers it. Nothing is created and nothing moves. msisdn is required because the network decides the charge.

Request
curl -X POST {{BASE}}/v1/disbursements/quote \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "msisdn": "0881234567", "amount": 500 }'
200 OK
{
  "provider": "tnm",
  "amount": "500.00",
  "fee_amount": "0.00",
  "provider_charge_amount": "10.28",
  "total_debit": "510.28",
  "available_balance": "84250.00",
  "sufficient_funds": true,
  "currency": "MWK"
}

Send a payout

POST/v1/disbursements

Pays money from your wallet to a mobile money number. Both networks usually answer straight away, so in most cases the response is the final result.

FieldDescription
msisdnrequiredRecipient's phone number.
amountrequiredAmount the recipient receives.
tenant_referencerequiredYour unique ID.
narrationoptionalReference shown to the recipient. Without it, your business name is used.
Request
curl -X POST {{BASE}}/v1/disbursements \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "msisdn": "0991234567",
    "amount": 100,
    "tenant_reference": "GROWER-PAYOUT-2219",
    "narration": "Tobacco sale 14 Oct"
  }'

Three possible answers

ResponseWhat it meansWhat to do
200 successThe recipient has been paid. Your wallet was debited wallet_amount.Mark it paid. No webhook follows; this response is the notification.
202 pendingThe network did not give a final answer (for example, it timed out). The money is held, not lost.Wait for the webhook, or look it up. Do not resend: it may already have been paid.
502 failedThe network refused the payout. The held money is back in your wallet.Look it up for failure_reason; fix the cause and retry with a new tenant_reference.
200 OK
{
  "gateway_reference": "TEST261014BJCFQ9H1I8",
  "tenant_reference": "GROWER-PAYOUT-2219",
  "provider": "airtel",
  "status": "success",
  "amount": "100.00",
  "fee_amount": "0.00",
  "provider_charge_amount": "2.06",
  "wallet_amount": "102.06",
  "message": "Disbursement completed"
}
502 Bad Gateway
{
  "error": "PROVIDER_REJECTED_REQUEST",
  "gateway_reference": "TEST261014Q8VW2MZK4N",
  "tenant_reference": "GROWER-PAYOUT-2220",
  "status": "failed"
}

Refusals before anything is sent, where nothing is held:

ResponseCause
402 INSUFFICIENT_FUNDSYour wallet balance does not cover amount plus fees.
423 WALLET_FROZENPayouts are paused on your wallet. Contact the operator.
503 PROVIDER_FLOAT_INSUFFICIENTThe gateway's payout account for that network is temporarily short. Retry later with a new tenant_reference.
503 PROVIDER_NOT_CONFIGUREDPayouts to that network are not enabled yet.

Look up one transaction

GET/v1/transactions/{tenant_reference}

Returns the current state, using your reference. If the transaction is still pending, the gateway first asks the network for the latest status, so a lookup can settle it.

Request
curl {{BASE}}/v1/transactions/ORDER-10045 \
  -H "Authorization: Bearer $API_KEY"
200 OK
{
  "tenant_reference": "ORDER-10045",
  "gateway_reference": "MLIMIP261014K3F9Q2X7A1",
  "type": "collection",
  "status": "failed",
  "amount": "1000.00",
  "wallet_amount": "1000.00",
  "msisdn": "265991234567",
  "failure_reason": "INSUFFICIENT_BALANCE",
  "failure_message": "You have insufficient funds. Please deposit money into your account and try again."
}
FieldDescription
tenant_referenceYour ID.
gateway_referenceThe gateway's ID. Quote it in support requests.
typecollection or disbursement.
statusSee transaction status.
amountAmount requested, or received by the payout recipient.
wallet_amountWhat your wallet gains (collection) or loses (payout) on success.
msisdnThe number, in 265… form.
failure_reasonOnly on failures. One of the failure reasons; otherwise null.
failure_messageOnly on failures. The network's own wording, safe to show to the customer.

An unknown reference returns 404 TRANSACTION_NOT_FOUND.

List transactions

GET/v1/transactions

Newest first. All query parameters are optional.

ParameterDescription
statuspending, success, failed, expired or reversed.
typecollection or disbursement.
providerairtel or tnm.
from, toDate or date-time, such as 2026-10-01.
limit, offsetPaging. limit defaults to 50, maximum 200.
Request
curl "{{BASE}}/v1/transactions?status=success&type=collection&from=2026-10-01&limit=100" \
  -H "Authorization: Bearer $API_KEY"
200 OK
{
  "total": 312,
  "limit": 100,
  "offset": 0,
  "transactions": [ { …same object as the single lookup… } ]
}

Wallet balance

GET/v1/wallets/balance
Request
curl {{BASE}}/v1/wallets/balance \
  -H "Authorization: Bearer $API_KEY"
200 OK
{
  "currency": "MWK",
  "balance": "84250.00",
  "hold_balance": "0.00",
  "status": "active"
}

balance is what you can spend now. hold_balance is money reserved for payouts that are still pending. It goes back to balance if they fail, and leaves the wallet if they succeed.

Wallet statement

GET/v1/wallets/statement

Every movement on your wallet, newest first. Takes from, to, limit (default 50, max 200) and offset.

200 OK
{
  "currency": "MWK",
  "balance": "84250.00",
  "hold_balance": "0.00",
  "total": 58,
  "limit": 50,
  "offset": 0,
  "entries": [
    {
      "entry_type": "credit",
      "amount": "1000.00",
      "balance_after": "84250.00",
      "description": "Collection",
      "tenant_reference": "ORDER-10045",
      "gateway_reference": "MLIMIP261014K3F9Q2X7A1",
      "transaction_type": "collection",
      "reference": null,
      "created_at": "2026-10-14T08:21:09.000Z"
    }
  ]
}

entry_type is one of credit, debit, hold, release, reversal, manual_credit or manual_debit. Manual entries are made by the operator, for example a bank top-up, and carry their reference.

Webhooks

The gateway POSTs a JSON message to your callback URL when a transaction reaches a final state that you have not already been told about in a response.

TransactionWebhook?
CollectionAlways, on success, failure or expiry.
Payout answered 200 success or 502 failedNot needed: the response was the result.
Payout answered 202 pendingYes, once the outcome is known.
Webhook body
{
  "event_id": "d04d3635-2b8a-ba25-d92c-05f5ba43db83",
  "tenant_reference": "ORDER-10045",
  "gateway_reference": "MLIMIP261014K3F9Q2X7A1",
  "type": "collection",
  "status": "success",
  "amount": "1000.00",
  "wallet_amount": "1000.00",
  "msisdn": "265991234567",
  "failure_reason": null,
  "failure_message": null,
  "timestamp": "2026-10-14T08:21:09.799Z"
}

The body is the same transaction object as the lookup, plus event_id and timestamp.

Your endpoint must

  • Be public HTTPS, and must not be the gateway's own address. Pointing it at the gateway makes it call itself.
  • Reply with any 2xx within 10 seconds. Do slow work after replying.
  • Deduplicate on event_id. Delivery is at least once, so the same event can arrive more than once. It has the same event_id every time, so credit your customer only on the first.

Retries: if your endpoint does not answer 2xx, the gateway retries up to 6 times, waiting 5 s, 10 s, 20 s, 40 s and 80 s between attempts. After that, a background job keeps trying about once a minute. Missed one anyway? Look the transaction up; it always has the current state.

Receiving webhooks

Webhooks are plain JSON POSTs with no signature and nothing to set up. They start arriving as soon as your callback URL is saved in the portal.

HeaderValue
Content-Typeapplication/json
X-Gateway-Event-IdSame as event_id in the body.
X-Gateway-Delivery-Attempt1 for the first try, then 2, 3 and so on.

Want to be certain before acting? Webhooks are not signed. Before releasing goods or crediting a customer, you can confirm with GET /v1/transactions/{tenant_reference}. That call needs your API key, so its answer always comes from the gateway.

Node.js (Express)
app.post('/gateway/callback', express.json(), (req, res) => {
  const event = req.body;
  res.sendStatus(200);                  // answer first, then work
  handleOnce(event.event_id, event);    // skip if event_id already processed
});
PHP
$event = json_decode(file_get_contents('php://input'), true);
http_response_code(200);
// Process once per $event['event_id'], e.g. a UNIQUE column on event_id.
if ($event['status'] === 'success') {
  // mark $event['tenant_reference'] as paid
}

Fees and network charges

Airtel and TNM take a charge on each transaction. Depending on how your account is set up, either the gateway covers it or it is added for the payer. Your operator may also set a service fee on your account.

FieldMeaning
fee_amountYour service fee on this transaction.
provider_charge_amountThe network charge you bear. "0.00" when the gateway covers it.
customer_amountCollections only: what the customer approves on their phone.
wallet_amountThe net effect on your wallet. For a collection it is what you are credited; for a payout it is what you are debited.

Example, K1,000 collection with the charge passed on to the payer: the customer approves about K1,020.56, and your wallet gets K1,000. For a payout, call quote first to see total_debit before sending.

Error codes

Every error has an error code. Most also have a message, and validation errors list each problem in details.

400 Bad Request
{
  "error": "VALIDATION_ERROR",
  "details": ["amount must have at most 2 decimal places"]
}
HTTPerrorFix
400VALIDATION_ERRORCorrect the fields listed in details.
401MISSING_API_KEY, INVALID_OR_INACTIVE_API_KEYCheck the Bearer header and key.
402INSUFFICIENT_FUNDSTop up your wallet or send less.
404TRANSACTION_NOT_FOUND, WALLET_NOT_FOUNDCheck the reference.
409DUPLICATE_TENANT_REFERENCEThat reference was already used. Look it up rather than resending.
422UNRECOGNIZED_MSISDN_PREFIXThe number is not an Airtel or TNM Malawi number.
422AMOUNT_OUTSIDE_PROVIDER_LIMITSAmount is below the network's minimum or above its maximum.
422FEE_EXCEEDS_AMOUNTThe amount is too small to cover its fees.
422RECIPIENT_NOT_FOUNDThe number has no active mobile money account.
423WALLET_FROZENContact the operator.
502PROVIDER_REJECTED_REQUESTThe network refused it; look the transaction up for failure_reason.
503PROVIDER_FLOAT_INSUFFICIENT, PROVIDER_NOT_CONFIGUREDTemporary on the gateway side. Retry later with a new reference.
500INTERNAL_ERRORLook the reference up before retrying; contact support with it.

Failure reasons

A failed transaction carries one of these in failure_reason, whichever network it was on. Base your logic on failure_reason, and show failure_message to people.

failure_reasonMeaning
INSUFFICIENT_BALANCEThe customer's mobile money balance was too low. For a payout, the network's payout account was short.
WRONG_PINThe customer entered the wrong PIN.
INSUFFICIENT_WALLET_FUNDSYour gateway wallet did not cover the payout.
WALLET_FROZENPayouts are paused on your wallet.
PAYOUT_FLOAT_UNAVAILABLEThe gateway's payout account for that network was short. Nothing was debited; retry later.
PROVIDER_DECLINEDThe network declined it for another reason; failure_message has its words.
PROVIDER_ERRORThe call to the network failed.

Go-live checklist

  • The API key is stored server-side, and no request is made from a browser or app.
  • Every request has a unique tenant_reference, saved before you call the gateway.
  • On a timeout or 5xx, you look the reference up instead of resending.
  • Your callback URL is set in the portal, is public HTTPS, and is your own server.
  • Your callback endpoint answers 2xx quickly and does slow work afterwards.
  • Webhooks are processed once per event_id.
  • Payouts treat 200 as paid, 202 as wait, 502 as failed and refunded.
  • Money is handled as decimal strings, never floats.