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.
099… is Airtel, 088… is TNM.Authentication
Every request carries your API key as a Bearer token:
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.
| Response | Meaning |
|---|---|
401 MISSING_API_KEY | No Authorization: Bearer … header was sent. |
401 INVALID_OR_INACTIVE_API_KEY | The 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).
| Field | Rule |
|---|---|
msisdn | The 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. |
amount | A 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_reference | Your 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. |
narration | Optional, 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
| Status | Final? | Meaning |
|---|---|---|
| pending | No | Sent to the network; the outcome is not known yet. Wait for the webhook, or look the transaction up. |
| success | Yes | Collection: the customer paid and your wallet was credited. Payout: the recipient was paid. |
| failed | Yes | Nothing moved, or held money was returned to your wallet. failure_reason says why. |
| expired | Usually | Collections only: the customer did not approve the prompt in time. If they approve late, it still becomes success and you get a webhook. |
| reversed | Yes | A completed transaction was reversed by the network or the operator. |
Collect a payment (STK push)
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.
| Field | Description | |
|---|---|---|
msisdn | required | Customer's phone number. |
amount | required | Amount to collect. |
tenant_reference | required | Your unique ID. |
narration | optional | Description of the payment. |
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"
}'{
"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_amountis what the customer is asked to approve. It is higher thanamountonly when your account passes the network charge on to the payer (see charges).wallet_amountis what your wallet will be credited on success.- If the network refuses the request outright, you get
502 PROVIDER_REJECTED_REQUESTwith"status": "failed", and nothing was charged.
Quote a payout
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.
curl -X POST {{BASE}}/v1/disbursements/quote \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{ "msisdn": "0881234567", "amount": 500 }'{
"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
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.
| Field | Description | |
|---|---|---|
msisdn | required | Recipient's phone number. |
amount | required | Amount the recipient receives. |
tenant_reference | required | Your unique ID. |
narration | optional | Reference shown to the recipient. Without it, your business name is used. |
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
| Response | What it means | What to do |
|---|---|---|
200 success | The recipient has been paid. Your wallet was debited wallet_amount. | Mark it paid. No webhook follows; this response is the notification. |
202 pending | The 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 failed | The 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. |
{
"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"
}{
"error": "PROVIDER_REJECTED_REQUEST",
"gateway_reference": "TEST261014Q8VW2MZK4N",
"tenant_reference": "GROWER-PAYOUT-2220",
"status": "failed"
}Refusals before anything is sent, where nothing is held:
| Response | Cause |
|---|---|
402 INSUFFICIENT_FUNDS | Your wallet balance does not cover amount plus fees. |
423 WALLET_FROZEN | Payouts are paused on your wallet. Contact the operator. |
503 PROVIDER_FLOAT_INSUFFICIENT | The gateway's payout account for that network is temporarily short. Retry later with a new tenant_reference. |
503 PROVIDER_NOT_CONFIGURED | Payouts to that network are not enabled yet. |
Look up one transaction
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.
curl {{BASE}}/v1/transactions/ORDER-10045 \
-H "Authorization: Bearer $API_KEY"{
"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."
}| Field | Description |
|---|---|
tenant_reference | Your ID. |
gateway_reference | The gateway's ID. Quote it in support requests. |
type | collection or disbursement. |
status | See transaction status. |
amount | Amount requested, or received by the payout recipient. |
wallet_amount | What your wallet gains (collection) or loses (payout) on success. |
msisdn | The number, in 265… form. |
failure_reason | Only on failures. One of the failure reasons; otherwise null. |
failure_message | Only on failures. The network's own wording, safe to show to the customer. |
An unknown reference returns 404 TRANSACTION_NOT_FOUND.
List transactions
Newest first. All query parameters are optional.
| Parameter | Description |
|---|---|
status | pending, success, failed, expired or reversed. |
type | collection or disbursement. |
provider | airtel or tnm. |
from, to | Date or date-time, such as 2026-10-01. |
limit, offset | Paging. limit defaults to 50, maximum 200. |
curl "{{BASE}}/v1/transactions?status=success&type=collection&from=2026-10-01&limit=100" \
-H "Authorization: Bearer $API_KEY"{
"total": 312,
"limit": 100,
"offset": 0,
"transactions": [ { …same object as the single lookup… } ]
}Wallet balance
curl {{BASE}}/v1/wallets/balance \
-H "Authorization: Bearer $API_KEY"{
"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
Every movement on your wallet, newest first. Takes from, to, limit (default 50, max 200) and offset.
{
"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.
| Transaction | Webhook? |
|---|---|
| Collection | Always, on success, failure or expiry. |
Payout answered 200 success or 502 failed | Not needed: the response was the result. |
Payout answered 202 pending | Yes, once the outcome is known. |
{
"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
2xxwithin 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 sameevent_idevery 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.
| Header | Value |
|---|---|
Content-Type | application/json |
X-Gateway-Event-Id | Same as event_id in the body. |
X-Gateway-Delivery-Attempt | 1 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.
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 });
$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.
| Field | Meaning |
|---|---|
fee_amount | Your service fee on this transaction. |
provider_charge_amount | The network charge you bear. "0.00" when the gateway covers it. |
customer_amount | Collections only: what the customer approves on their phone. |
wallet_amount | The 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.
{
"error": "VALIDATION_ERROR",
"details": ["amount must have at most 2 decimal places"]
}| HTTP | error | Fix |
|---|---|---|
| 400 | VALIDATION_ERROR | Correct the fields listed in details. |
| 401 | MISSING_API_KEY, INVALID_OR_INACTIVE_API_KEY | Check the Bearer header and key. |
| 402 | INSUFFICIENT_FUNDS | Top up your wallet or send less. |
| 404 | TRANSACTION_NOT_FOUND, WALLET_NOT_FOUND | Check the reference. |
| 409 | DUPLICATE_TENANT_REFERENCE | That reference was already used. Look it up rather than resending. |
| 422 | UNRECOGNIZED_MSISDN_PREFIX | The number is not an Airtel or TNM Malawi number. |
| 422 | AMOUNT_OUTSIDE_PROVIDER_LIMITS | Amount is below the network's minimum or above its maximum. |
| 422 | FEE_EXCEEDS_AMOUNT | The amount is too small to cover its fees. |
| 422 | RECIPIENT_NOT_FOUND | The number has no active mobile money account. |
| 423 | WALLET_FROZEN | Contact the operator. |
| 502 | PROVIDER_REJECTED_REQUEST | The network refused it; look the transaction up for failure_reason. |
| 503 | PROVIDER_FLOAT_INSUFFICIENT, PROVIDER_NOT_CONFIGURED | Temporary on the gateway side. Retry later with a new reference. |
| 500 | INTERNAL_ERROR | Look 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_reason | Meaning |
|---|---|
INSUFFICIENT_BALANCE | The customer's mobile money balance was too low. For a payout, the network's payout account was short. |
WRONG_PIN | The customer entered the wrong PIN. |
INSUFFICIENT_WALLET_FUNDS | Your gateway wallet did not cover the payout. |
WALLET_FROZEN | Payouts are paused on your wallet. |
PAYOUT_FLOAT_UNAVAILABLE | The gateway's payout account for that network was short. Nothing was debited; retry later. |
PROVIDER_DECLINED | The network declined it for another reason; failure_message has its words. |
PROVIDER_ERROR | The 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
2xxquickly and does slow work afterwards. - Webhooks are processed once per
event_id. - Payouts treat
200as paid,202as wait,502as failed and refunded. - Money is handled as decimal strings, never floats.