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"
}

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
failureReasonstringNoPresent only on *.failed events
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 or expired
payment.refundedCollectionThe collection was refunded or reversed
payout.approvedDisbursementThe payout was approved and debited
payout.confirmedDisbursementThe payout reached SUCCESSFUL
payout.failedDisbursementThe payout failed. The balance has already been refunded

Intermediate states fire nothing, so there is no "processing" webhook to wait for.

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

Retry Policy

If your endpoint does not respond with a 2xx status code within 30 seconds, MALIPOPAY retries the delivery up to 5 times with exponential backoff:

AttemptDelay
11 minute
25 minutes
330 minutes
42 hours
512 hours

After 5 failed attempts, the webhook is marked as failed. You can view failed deliveries in the dashboard and trigger a manual re-send.

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.