Webhooks
MALIPOPAY sends HTTP POST requests to your configured callback URL when payment events occur. Use webhooks to update your system in real time: mark orders as paid, trigger fulfillment, send receipts, etc.
Setup
- Log in to the MALIPOPAY Dashboard.
- Go to Settings > Webhooks.
- Enter your callback URL (must be HTTPS in production).
- Save. MALIPOPAY will send a test ping to verify your endpoint responds with
200 OK.
Callback Payload
When a payment event occurs, MALIPOPAY sends a POST request with this JSON body:
{
"timestamp": "20260818081604",
"reference": "ML00365",
"customerReference": "YOUR REF NUMBER FROM SYSTEM",
"amount": 10000,
"merchantAccountId": "Duka Instagram",
"status": "SUCCESSFUL",
"type": "CHARGE",
"customer": {
"firstname": "John",
"lastname": "Deo",
"phoneNumber": "255655128812",
"mno": "Tigo"
},
"transactionId": "MP250001234567",
"payloadSignature": "b875460229adc88cef4bd9904b0b06ba4b2cb4...",
"event": "payment.confirmed",
"service": "Collection",
"message": "Success"
}
A failed collection carries the same shape. message is the only field that
tells you the outcome, and it is safe to show a customer as-is:
{
"timestamp": "20260910103428",
"reference": "ML00366",
"customerReference": "YOUR REF NUMBER FROM SYSTEM",
"amount": 0,
"merchantAccountId": "Duka Instagram",
"status": "FAILED",
"type": "CHARGE",
"customer": {
"firstname": "John",
"lastname": "Deo",
"phoneNumber": "255655128812",
"mno": "Vodacom"
},
"transactionId": "-1",
"payloadSignature": "2218ed71249f05f983141a226e363b2e4f9702f09ed5846e...",
"event": "payment.failed",
"service": "Collection",
"message": "The customer cancelled the payment prompt."
}
On a failure amount is 0 because nothing was received, and transactionId
is -1 because the operator issued no receipt. Do not treat -1 as a real id.
Field Reference
| Field | Type | Required | Description |
|---|---|---|---|
timestamp | string | Yes | Format: yyyymmddHHMMss, e.g. 20260818081604 |
reference | string | Yes | Unique payment reference for reconciliation |
customerReference | string | No | Your custom reference (passed during payment initiation) |
amount | number | Yes | The amount actually paid, in TZS. On a part payment this is less than the amount requested |
type | string | Yes | The payment mode: CHARGE or PAYOUT |
merchantAccountId | string | Yes | Your merchant/project name |
status | string | Yes | The payment status verbatim, e.g. SUCCESSFUL, FAILED, APPROVED. See Payment Statuses |
customer | object | Yes | Customer details (see below) |
transactionId | string | No | The operator's own transaction id, when they returned one |
event | string | Yes | Canonical event name. Route on this, not on status |
service | string | Yes | Collection or Disbursement, so one endpoint can host both |
message | string | Yes | A human-readable line about this event, safe to show a customer or log as-is. On a failure it explains why, for example "The customer cancelled the payment prompt." This is the only outcome field: the operator's own code and raw wording are not sent, because they are machine tokens rather than answers. If you need them for a dispute, they are on the payment record via the API |
payloadSignature | string | Yes | Legacy in-body signature (deprecated). Verify the X-Malipopay-Signature header instead |
Customer object:
| Field | Type | Description |
|---|---|---|
firstname | string | Customer first name |
lastname | string | Customer last name |
phoneNumber | string | Phone number (e.g. 255655128812) |
mno | string | Mobile network operator (Tigo, Vodacom, Airtel, Halotel) |
Events
Route on the event field. These are the only events that fire:
| Event | service | Fired when |
|---|---|---|
payment.confirmed | Collection | The collection reached SUCCESSFUL, PAID or PARTIAL |
payment.failed | Collection | The collection reached FAILED, REJECTED, CANCELLED, USER_REJECTED or expired |
payment.refunded | Collection | The collection was refunded or reversed |
payout.approved | Disbursement | The payout was approved and debited |
payout.confirmed | Disbursement | The payout reached SUCCESSFUL |
payout.failed | Disbursement | The payout reached FAILED, REJECTED, CANCELLED, USER_REJECTED or expired. The balance has already been refunded |
invoice.created | Invoicing | An invoice was created |
invoice.updated | Invoicing | An invoice was changed |
invoice.paid | Invoicing | An invoice was paid in full, with nothing left outstanding |
invoice.cancelled | Invoicing | An invoice moved to CANCELLED |
sms.delivered | Bulk SMS | The operator reported the message reached the handset |
sms.failed | Bulk SMS | The operator reported a terminal state that is not a delivery |
Intermediate states fire nothing, so there is no "processing" webhook to wait for.
sms.* fires on the operator's terminal delivery report, not when a message is
accepted for sending. Some messages never reach a terminal state, because an
operator can send a report MALIPOPAY does not recognise and those are left alone
rather than guessed at, so do not block a flow waiting for sms.delivered.
On an SMS event reference is the message id and customerReference is the
recipient's number; amount is 0, because no money moved.
invoice.updated covers any other change to an invoice. Re-saving an invoice
that is already cancelled or already paid is an update, not a second
cancellation or a second payment, so those two fire once on the transition and
not again.
A part payment against an invoice does NOT fire invoice.paid, because the
invoice is not settled. It arrives as payment.confirmed carrying the amount
that moved; read the invoice block on it for what is still owed.
Invoice events carry an extra block
An invoice has three amounts where a payment has one, so invoice.* deliveries
add an invoice object. The top-level amount is the invoice TOTAL on every
one of them, including invoice.paid.
{
"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"
}
}
reference on an invoice event is the invoice NUMBER, and paymentReference is
the standing control number a customer quotes when paying it. The invoice key
is absent from Collection and Disbursement deliveries, so a receiver written
against the payment shape is unaffected.
You may register up to 3 webhook URLs. Every one of them receives every matching event, so if you fan out to several systems, each must be independently idempotent.
Signature Verification
Every delivery carries an X-Malipopay-Signature header. Always verify it before processing the payment.
X-Malipopay-Signature: sha256=<hex>
The value is an HMAC-SHA256 of the raw request body, keyed by your webhook's signing secret:
signature = HMAC_SHA256(rawRequestBody, webhookSigningSecret)
Get the signing secret from the dashboard under Settings > Webhooks (each webhook has its own). It is not your API key: you can re-view it at any time, and rotating your API key does not affect it.
To verify, recompute the HMAC over the exact raw bytes you received (do not re-serialize the parsed JSON; key order and spacing must match), then compare against the header value using a constant-time comparison.
Older deliveries also carry a payloadSignature field inside the JSON body. It is deprecated. Verify with the X-Malipopay-Signature header instead.
With the SDK (recommended)
The official SDKs (malipopay for Node.js and Python, malipopay/malipopay-php, malipopay gem) do the comparison for you. Pass the signing secret and the raw body:
import { Webhooks } from "malipopay";
// The per-webhook signing secret from Settings > Webhooks
const webhooks = new Webhooks(process.env.MALIPOPAY_WEBHOOK_SECRET);
// IMPORTANT: give the SDK the RAW body, not a parsed object.
app.post(
"/webhooks/malipopay",
express.raw({ type: "application/json" }),
(req, res) => {
try {
const event = webhooks.constructEvent(
req.body, // raw Buffer
req.headers["x-malipopay-signature"], // "sha256=..."
);
// event.reference, event.status, event.amount ...
res.sendStatus(200);
} catch {
res.sendStatus(400); // signature invalid: reject
}
},
);
Node.js (without the SDK)
const crypto = require("crypto");
function isValid(rawBody, header, secret) {
const provided = (header || "").replace(/^sha256=/, "");
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(provided);
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post("/webhooks/malipopay", express.raw({ type: "application/json" }), (req, res) => {
if (!isValid(req.body, req.headers["x-malipopay-signature"], process.env.MALIPOPAY_WEBHOOK_SECRET)) {
return res.status(401).json({ error: "Invalid signature" });
}
const payload = JSON.parse(req.body); // safe to parse now
// payload.reference, payload.status, payload.amount ...
res.status(200).json({ received: true });
});
Python (without the SDK)
import hashlib
import hmac
from flask import Flask, request, jsonify
app = Flask(__name__)
def is_valid(raw_body: bytes, header: str, secret: str) -> bool:
provided = (header or "").removeprefix("sha256=")
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(provided, expected)
@app.route("/webhooks/malipopay", methods=["POST"])
def handle_webhook():
if not is_valid(request.get_data(), request.headers.get("X-Malipopay-Signature"), "your-webhook-signing-secret"):
return jsonify(error="Invalid signature"), 401
payload = request.get_json() # safe to parse now
# payload["reference"], payload["status"] ...
return jsonify(received=True), 200
Delivery
Each event is delivered as a single POST. We wait up to 30 seconds for your response.
Payment webhooks are not retried. If your endpoint is down, times out, or answers
anything other than a 2xx, that notification is not delivered again, and there is no
manual re-send. Treat the webhook as the fast path, not the only path.
If you may have missed a delivery, reconcile against the payment itself. It is the authoritative record and it does not expire:
GET /api/v1/payment/reference/{reference}
Two habits make a missed delivery a non-event:
- Acknowledge first, process after. Return
200as soon as you have stored the raw body, and do your own work outside the request. A slow handler is the most common reason a notification is lost. - Reconcile on a schedule. Sweep your own unresolved records against the payment API periodically. A collection that has not resolved within the 90 second window is not going to resolve on its own.
WhatsApp webhooks are a separate system and are retried, five times over roughly twelve hours. See WhatsApp Webhooks. The behaviour on this page does not apply there, or the other way round.
Best Practices
- Always verify the signature before processing. Never trust the payload blindly.
- Respond quickly with
200 OKand process asynchronously. Long-running work should be queued. - Be idempotent. You may receive the same callback more than once (retries). Use the
referencefield to deduplicate. - Use HTTPS. Your webhook URL must be an HTTPS endpoint.
Testing
Expose your local server via a tunnel and point your dashboard webhook URL at it:
# Using ngrok
ngrok http 3000
# Copy the https:// URL into your dashboard webhook settings
Then run a real collection against one of your registered test recipients and watch the callback arrive locally. There is no separate environment to switch to: while your account is awaiting go-live approval, live transactions are already restricted to your test recipients and a small daily cap. See Testing.