Skip to main content

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​

  1. Log in to the MALIPOPAY Dashboard.
  2. Go to Settings > Webhooks.
  3. Enter your callback URL (must be HTTPS in production).
  4. 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​

FieldTypeRequiredDescription
timestampstringYesFormat: yyyymmddHHMMss, e.g. 20260818081604
referencestringYesUnique payment reference for reconciliation
customerReferencestringNoYour custom reference (passed during payment initiation)
amountnumberYesThe amount actually paid, in TZS. On a part payment this is less than the amount requested
typestringYesThe payment mode: CHARGE or PAYOUT
merchantAccountIdstringYesYour merchant/project name
statusstringYesThe payment status verbatim, e.g. SUCCESSFUL, FAILED, APPROVED. See Payment Statuses
customerobjectYesCustomer details (see below)
transactionIdstringNoThe operator's own transaction id, when they returned one
eventstringYesCanonical event name. Route on this, not on status
servicestringYesCollection or Disbursement, so one endpoint can host both
messagestringYesA 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
payloadSignaturestringYesLegacy in-body signature (deprecated). Verify the X-Malipopay-Signature header instead

Customer object:

FieldTypeDescription
firstnamestringCustomer first name
lastnamestringCustomer last name
phoneNumberstringPhone number (e.g. 255655128812)
mnostringMobile network operator (Tigo, Vodacom, Airtel, Halotel)

Events​

Route on the event field. These are the only events that fire:

EventserviceFired when
payment.confirmedCollectionThe collection reached SUCCESSFUL, PAID or PARTIAL
payment.failedCollectionThe collection reached FAILED, REJECTED, CANCELLED, USER_REJECTED or expired
payment.refundedCollectionThe collection was refunded or reversed
payout.approvedDisbursementThe payout was approved and debited
payout.confirmedDisbursementThe payout reached SUCCESSFUL
payout.failedDisbursementThe payout reached FAILED, REJECTED, CANCELLED, USER_REJECTED or expired. The balance has already been refunded
invoice.createdInvoicingAn invoice was created
invoice.updatedInvoicingAn invoice was changed
invoice.paidInvoicingAn invoice was paid in full, with nothing left outstanding
invoice.cancelledInvoicingAn invoice moved to CANCELLED
sms.deliveredBulk SMSThe operator reported the message reached the handset
sms.failedBulk SMSThe 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.

note

Older deliveries also carry a payloadSignature field inside the JSON body. It is deprecated. Verify with the X-Malipopay-Signature header instead.

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 200 as 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 OK and process asynchronously. Long-running work should be queued.
  • Be idempotent. You may receive the same callback more than once (retries). Use the reference field 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.