Skip to main content

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:

FieldMeaning
serviceHow the payer is identified: mobile, account, tanqr or merchant
accountThe identifier itself: phone number, bank account number, or till id
providerThe 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

StepEndpointNotes
1POST /api/v2/payment/collectionInitiates the charge. Returns immediately with status: PROCESSING.
GET /api/v2/payment/channelsWhich channels this project may collect on. Read it instead of hard-coding a list.
10POST <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/payRe-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 FAILED with reason TIMEOUT.
  • 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/collection with the same reference returns the existing payment instead of charging again. Use your own order id as the reference and a retried request costs the customer nothing.