Status Codes
Every webhook MALIPOPAY sends carries a numeric code alongside a human-readable message.
Branch on the code. Show the message. The code is stable and will not change wording; the message is written for a person and may be reworded or translated.
{
"reference": "ML00366",
"status": "FAILED",
"event": "payment.failed",
"service": "Collection",
"code": 91051,
"message": "The customer cancelled the payment prompt."
}
How to read a code
9 1 051
│ │ └── outcome: 00-49 success, 50-99 failure
│ └──── service: 1 Collection, 2 Disbursement
└────── webhook event codes
A reason keeps the same last two digits in every service. 51 means "the customer
cancelled" whether it arrived on a collection or a payout, so code % 100 is meaningful
on its own when you do not care which service produced it.
The single most useful check is the success/failure split:
const failed = code % 100 >= 50;
Collection
| Code | Event | Meaning |
|---|---|---|
91000 | payment.confirmed | Paid in full |
91001 | payment.confirmed | Part paid. Compare amount with what you requested |
91010 | payment.refunded | Refunded or reversed |
91050 | payment.failed | Failed at the operator, no further detail available |
91051 | payment.failed | The customer cancelled the prompt |
91052 | payment.failed | The customer did not confirm in time |
91053 | payment.failed | The customer does not have enough balance |
91054 | payment.failed | The PIN entered was not accepted |
91055 | payment.failed | The operator rejected the request |
91056 | payment.failed | The operator did not answer in time |
91057 | payment.failed | The number is not reachable on that network |
91058 | payment.failed | A transaction limit was reached |
91059 | payment.failed | The customer's account is barred or closed |
91060 | payment.failed | The operator treated this as a duplicate |
91061 | payment.failed | We could not reach the operator |
91062 | payment.failed | MALIPOPAY float was insufficient. Contact support |
Disbursement
| Code | Event | Meaning |
|---|---|---|
92000 | payout.confirmed | Completed |
92002 | payout.approved | Approved and debited from your wallet |
92050 | payout.failed | Failed, no further detail available |
92051 | payout.failed | The customer cancelled |
92052 | payout.failed | Not confirmed in time |
92053 | payout.failed | Insufficient balance |
92054 | payout.failed | The PIN entered was not accepted |
92055 | payout.failed | The operator rejected the payout |
92056 | payout.failed | The operator did not answer in time |
92057 | payout.failed | The recipient is not reachable on that network |
92058 | payout.failed | A transaction limit was reached |
92059 | payout.failed | The recipient's account is barred or closed |
92060 | payout.failed | The operator treated this as a duplicate |
92061 | payout.failed | We could not reach the operator |
92062 | payout.failed | MALIPOPAY float was insufficient. Contact support |
A failed payout is refunded to your wallet automatically.
Invoicing
| Code | Event | Meaning |
|---|---|---|
93000 | invoice.created | An invoice was created |
93001 | invoice.updated | An invoice was changed |
93002 | invoice.paid | An invoice was paid in full |
93003 | invoice.cancelled | An invoice was cancelled |
Invoicing has no failure codes: an invoice that is not paid is simply still outstanding, and the payment attempt against it reports its own outcome on the Collection codes above.
invoice.paid fires only when nothing is left outstanding. A part payment
against an invoice does not fire it, because you have not been settled; that
payment arrives as payment.confirmed carrying the amount that moved, and the
invoice block on it tells you what is still owed.
Every invoice event carries an extra invoice object, because an invoice has
three amounts where a payment has one:
{
"code": 93002,
"event": "invoice.paid",
"service": "Invoicing",
"message": "The invoice was paid in full.",
"reference": "INV-0042",
"amount": 120000,
"invoice": {
"invoiceNo": "INV-0042",
"total": 120000,
"amountPaid": 120000,
"amountDue": 0,
"currency": "TZS",
"dueDate": "2026-10-01",
"paidDate": "2026-09-12T08:14:22.000Z",
"paymentReference": "MI24099"
}
}
The top-level amount is the invoice TOTAL on every invoice event, including
invoice.paid. Read invoice.amountPaid for what has been received and
invoice.amountDue for what is still owed. The invoice key is present only on
invoice.* events; a Collection or Disbursement body is unchanged.
Bulk SMS
| Code | Event | Meaning |
|---|---|---|
94000 | sms.delivered | The message reached the handset |
94050 | sms.failed | Not delivered, no further detail available |
94055 | sms.failed | The operator rejected the message |
94056 | sms.failed | The operator gave up before it could be delivered |
That is the whole list, and it is short on purpose. The other offsets in the
shared failure vocabulary (51 cancelled, 53 insufficient balance, 57 not
reachable, and so on) are not published for Bulk SMS, because an SMPP
delivery report does not carry enough to tell them apart and MALIPOPAY does not
guess. A wrong reason is worse than no reason when you may automate on it. If
richer per-rail detail becomes available the codes will be added here, using the
same offsets they already have in every other band.
reference on an SMS event is the message id from the send, and
customerReference is the recipient's number. An SMS moves no money, so
amount is always 0.
These fire on the operator's TERMINAL delivery report, not when the message is accepted. A message the operator has taken but not yet delivered produces nothing, which is deliberate: there is no "in flight" event to wait for.
Some messages never reach a terminal state at all, because an operator can
send a report we do not recognise, and MALIPOPAY leaves those alone rather
than guessing. Do not build a flow that blocks until sms.delivered
arrives. Treat it as confirmation when it comes, not as a step that is owed
to you.
Anything that is not an explicit delivery reports as sms.failed. That is on
purpose: a terminal state added by an operator should reach you as a failure you
can see and ask about, rather than being dropped or announced as a delivery.
Which codes you can act on
Most failure codes tell you whether retrying is worth anything:
| Code | Retry? |
|---|---|
91052, 91056, 91061 | Yes. Nobody decided anything; the attempt did not complete |
91051, 91054 | Only if the customer asks. They cancelled or mistyped |
91053, 91058 | Not immediately. The money or the headroom is not there |
91057, 91059 | No. Fix the number or have the customer contact their operator |
91060 | No. Check whether an earlier attempt already succeeded |
91060 means the operator saw this as a repeat. Before retrying, check the payment with
GET /api/v1/payment/reference/{reference} — an earlier attempt may already have been paid.
Handling them
app.post("/webhooks/malipopay", (req, res) => {
res.sendStatus(200); // acknowledge first, work after
const { code, reference, message } = req.body;
if (code < 91050) return fulfil(reference); // any success
if ([91052, 91056, 91061].includes(code)) return scheduleRetry(reference);
return showCustomer(reference, message); // message is safe to display
});
WhatsApp
WhatsApp messages have their own band and their own delivery system. They are not configured on the Webhooks screen, and they do not use the payment webhook's signature scheme. See WhatsApp Webhooks.
| Code | Event | Meaning |
|---|---|---|
95000 | whatsapp.message.sent | Accepted by WhatsApp |
95001 | whatsapp.message.delivered | Delivered to the handset |
95002 | whatsapp.message.read | Read by the recipient |
95003 | whatsapp.message.received | An inbound message from a customer |
95050 | whatsapp.message.failed | Failed, with no recognised cause |
95057 | whatsapp.message.failed | The number cannot receive WhatsApp messages |
95058 | whatsapp.message.failed | A WhatsApp rate limit was reached |
95059 | whatsapp.message.failed | The account is locked or restricted |
95063 | whatsapp.message.failed | The 24 hour service window has closed |
95064 | whatsapp.message.failed | The recipient opted out, or Meta withheld it |
95065 | whatsapp.message.failed | The template was rejected, missing, or its parameters did not match |
95066 | whatsapp.message.failed | A billing problem on the business account. Contact support |
95063 is the one worth handling deliberately. It means the free-form window has
closed and the customer must be reached with an approved template instead. It is the
most common WhatsApp failure and it is not an error on your side.
A WhatsApp webhook URL is set per phone number through the API, not on the Webhooks screen. If you are not receiving WhatsApp events, that is almost certainly why.
Everything in this guide now fires
Every code above is emitted by the platform today. Invoicing (93xxx),
payout.approved (92002) and Bulk SMS (94xxx) were all on a "not yet
emitted" list until 2026-09-12 and have since shipped.
If you subscribed to any of them before that date, no action is needed: the subscription was already stored and deliveries simply begin arriving.
API responses are a different set
REST responses carry their own code in the envelope:
{ "success": true, "code": 1109, "message": "Collection request sent successfully", "data": {} }
These belong to the API, not to webhook events, and the two spaces do not overlap.
Each MALIPOPAY service owns its own range, so one number always means one thing:
| Range | Service |
|---|---|
1xxx | Core payments API |
2xxxx | Payment gateway |
3xxxx | Messaging |
4xxxx | Authentication |
5xxxx | API gateway |
6xxxx | Short links |
7xxxx | USSD |
9xxxx | Webhook events (this page) |
You will normally only see the 1xxx range, since that is the API you call. A code from another range means the failure came from a service further in, and the message will say what happened.
See Error Handling for the HTTP statuses.
Stability
A code, once published here, keeps its meaning. New codes are added for new outcomes; existing ones are not repurposed.
If you receive a code that is not on this page, treat it by its band: code % 100 >= 50 is a failure, and message will still say what happened. That is the intended way to stay forward-compatible.