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": "20221002123003",
"reference": "ML00365",
"customerReference": "YOUR REF NUMBER FROM SYSTEM",
"amount": 10000,
"type": "CHARGE",
"merchantAccountId": "Duka Instagram",
"status": "Success",
"customer": {
"firstname": "John",
"lastname": "Deo",
"phoneNumber": "255655128812",
"mno": "Tigo"
},
"payloadSignature": "b875460229adc88cef4bd9904b0b06ba4b2cb4..."
}

Field Reference

FieldTypeRequiredDescription
timestampstringYesFormat: yyyymmddhhmiss
referencestringYesUnique payment reference for reconciliation
customerReferencestringNoYour custom reference (passed during payment initiation)
amountnumberYesTransaction amount in TZS
typestringYesTransaction type, e.g. CHARGE
merchantAccountIdstringYesYour merchant/project name
statusstringYesSuccess or Failed
customerobjectYesCustomer details (see below)
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)

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 in production. HTTP endpoints are only accepted in UAT/staging.

Testing

During development, use your UAT API keys and expose your local server via a tunnel:

# Using ngrok
ngrok http 3000
# Copy the https:// URL into your dashboard webhook settings

Send a test payment via the UAT environment and watch the callback arrive at your local endpoint.