Payment Collection Flow
Overview
A collection charges a customer. Mobile money, bank accounts, TANQR and till / Lipa Numbers all go through the same endpoint, POST /api/v2/payment/collection. You describe who is paying rather than how the plumbing works, and MALIPOPAY routes it.
Three fields carry that description:
| Field | Meaning |
|---|---|
service | How the payer is identified: mobile, account, tanqr or merchant |
account | The identifier itself: phone number, bank account number, or till id |
provider | The channel, e.g. VODACOM, CRDB. Optional for mobile because the network is detected from the number |
Mobile numbers may be sent as 0712345678 or 255712345678; both are normalised to 255712345678 before dispatch.
amountType controls how much the customer may pay: FULL requires the exact amount, FIXED accepts a single payment of a set amount, and FLEXIBLE accepts part payments until the total is met (watch paidAmount against amount).
Set push: false to create the payment without prompting the customer immediately, for example when you intend to collect it later through a payment link or the paybill.
Sequence diagram
Key endpoints
| Step | Endpoint | Notes |
|---|---|---|
| 1 | POST /api/v2/payment/collection | Initiates the charge. Returns immediately with status: PROCESSING. |
| — | GET /api/v2/payment/channels | Which channels this project may collect on. Read it instead of hard-coding a list. |
| 10 | POST <merchant webhook> | Final status push to your configured webhook URL. |
| — | GET /api/v1/payment/reference/{reference} | Poll-based status check, if your webhook is unreachable. |
| — | POST /api/v2/payment/pay | Re-route an existing reference to a different channel. |
What the fee fields mean
A successful collection credits your wallet net of the fee, and the payment object reports what was taken:
{
"amount": 50000,
"feeAmount": 1250,
"feePercent": 2.5,
"netAmount": 48750
}
These three keys are omitted entirely on collections that predate the upfront fee model. An absent feeAmount means there is no per-payment fee to report, which is a different statement from a fee of zero. Read them with a null check, not a falsy check.
Webhook payload
The final webhook POST body follows this shape:
{
"timestamp": "20260818081604",
"reference": "ML008985",
"customerReference": "ORDER-001",
"amount": 50000,
"merchantAccountId": "Lockwood Technology",
"status": "SUCCESSFUL",
"type": "CHARGE",
"customer": {
"firstname": "JOHN",
"lastname": "HAULE",
"phoneNumber": "255712345678",
"mno": "Vodacom"
},
"transactionId": "MP250001234567",
"payloadSignature": "b875460229adc88cef4bd9904b0b06ba4b2cb4...",
"event": "payment.confirmed",
"service": "Collection"
}
reference is the MALIPOPAY reference; customerReference echoes the reference you sent. amount on the webhook is the amount actually paid, which can be less than the amount requested on a FLEXIBLE collection. timestamp is yyyymmddHHMMss, and status carries the payment status verbatim, so route on event rather than parsing status.
Collections fire payment.confirmed, payment.failed or payment.refunded. Intermediate states such as PROCESSING fire nothing, so do not wait for a webhook that says "in progress".
See Webhooks for signature verification and retry semantics.
Timeouts and retries
- USSD push timeout: 90 seconds for the customer to complete PIN entry. After that the operator returns
FAILEDwith reasonTIMEOUT. - Webhook retry: MALIPOPAY retries up to 5 times with exponential backoff (1s, 5s, 30s, 2m, 10m) until you return HTTP 2xx.
- Idempotency: repeating
POST /api/v2/payment/collectionwith the samereferencereturns the existing payment instead of charging again. Use your own order id as thereferenceand a retried request costs the customer nothing.