Card Payments
Malipopay takes card payments through NMB Bank's Mastercard Payment Gateway Services (MPGS). The payer types the card into fields that the gateway itself hosts, so the card number, expiry and security code never pass through your page, your servers or Malipopay's. The issuer authenticates the payer with 3D Secure where the card supports it, and Malipopay captures the money and tells you through the same payment.confirmed webhook you already receive for mobile money.
Card networks. Mastercard is verified end to end. On UAT only Mastercard approves: Visa test cards decline, and American Express and Maestro are refused by the gateway. On production, which networks are accepted depends on NMB's acquiring setup for Malipopay; Visa acceptance on live has not been verified yet, so confirm it with Malipopay before you advertise it to payers.
A card payment is a normal collection. It has a Malipopay reference, it shows in your dashboard beside every other payment, and it can be read with GET /api/v1/payment/verify/{reference}.
Card numbers, expiry dates and security codes must only ever be typed into the gateway-hosted fields (the hosted checkout, the embeddable element, or the gateway's own session.js or Mobile SDK in your client). Malipopay's API refuses them: a request to POST /api/v1/payment or PUT /api/v1/payment (v1 or v2) that carries any of cardNumber, cvc, cvv, securityCode, expiryMonth, expiryYear or pan answers 400 with code: "CARD_DETAILS_NOT_ACCEPTED", and the request is neither logged nor stored. See Raw card details are refused.
Read Testing Card Payments alongside this page. Card testing is different from mobile money testing: UAT has a real card sandbox with test cards, and production has none. Every card payment on production is a real charge to a real card.
Environments
| UAT (sandbox) | Production (live) | |
|---|---|---|
| API base URL | https://core-uat.malipopay.co.tz | https://core-prod.malipopay.co.tz |
| Dashboard and hosted checkout | https://demo.malipopay.co.tz | https://app.malipopay.co.tz |
| Embeddable element | https://demo.malipopay.co.tz/embed/card.js | https://app.malipopay.co.tz/embed/card.js |
| Card gateway | Mastercard test gateway (test-nmbbank.mtf.gateway.mastercard.com) | NMB live gateway (nmbbank.gateway.mastercard.com) |
| Cards that work | Test cards only, Mastercard only, TZS 1,000 or less to approve. See the verified list | Real cards only |
| Money moves | No | Yes. The payer's card is charged |
| 3D Secure challenge | Not available. Every card answers AUTHENTICATION_NOT_SUPPORTED and is captured without a challenge | Shown by the issuer when the card is enrolled |
Which gateway a payment uses is decided by the mode of the project that owns it: a project in test mode goes to the test gateway, a live project goes to the live gateway. Nothing you send in a request can switch it, and a test card can never charge anything on production.
How it works
- Create the payment. Your server calls
POST /api/v1/payment/card/intentwith yourapiToken. You get back a Malipopayreference. - Open a hosted session. The payer's browser (or app) calls
POST /api/v1/payment/card/initSession. Malipopay creates a session on the gateway and returns its id. - Bind the payment to the session. The browser calls
POST /api/v1/payment/card/prepare/{session}/{reference}. Malipopay writes the order, the amount (read from the payment, never from the browser), the 3D Secure channel and the return address onto the session. - Collect the card. The gateway's own
session.jsturns your empty input boxes into gateway-hosted fields. The payer types the card, and the fields write it straight into the session. - Authenticate. The gateway's
three-ds.jsasks the issuer whether the card supports 3D Secure.- If it does not, the browser calls
POST /api/v1/payment/card/complete/{reference}and Malipopay captures at once. - If it does, the page goes full screen to the issuer's challenge (an OTP or a banking app approval). When the payer finishes, the gateway sends the browser to Malipopay's return address, and Malipopay captures there.
- If it does not, the browser calls
- Capture. Malipopay reads the authentication result back from the gateway, never from the browser, and issues the PAY. A capture is credited to the payment once, however many times it is reported.
- Result. The payer lands on the receipt or back on your page with the outcome, and your server receives
payment.confirmed.
Integration options
Pick the one that fits. All three end in the same captured payment and the same webhook.
| Option | You build | Best for |
|---|---|---|
| Hosted checkout link | Nothing on the front end. You redirect or send a link | Invoices, links in SMS or WhatsApp, the fastest start |
| Embeddable card element | One script tag and one call on your own page | Keeping the payer on your site |
| Direct API | Your own client around the gateway's scripts or Mobile SDK | Native apps and fully custom checkouts |
Option 1: Hosted checkout link
Create the payment on your server, then send the payer to checkoutUrl from the response. The Malipopay checkout shows the card form (and mobile money), runs 3D Secure, and captures.
curl -X POST https://core-prod.malipopay.co.tz/api/v1/payment/card/intent \
-H "apiToken: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 25000,
"currency": "TZS",
"description": "Order 42",
"customerReference": "ORDER-42"
}'
What the payer sees after they pay:
| Outcome | Where the payer lands |
|---|---|
| Paid in full, no challenge | The checkout itself confirms the payment, on the same page |
| Paid in full after a 3D Secure challenge | Straight to the receipt page, https://app.malipopay.co.tz/r/{slug} |
| Part paid (only when part payments are on, see below) | The payment page for the reference, showing what is still owed |
| Declined or not authenticated | Back on the checkout, with a message and the card form ready for another try |
Any existing Malipopay payment link or invoice link also offers the card option on the same checkout. A Pay Me page payment made with method: "card" answers with a redirectUrl of the form https://app.malipopay.co.tz/checkout?ref={reference}&method=card, which opens the checkout straight on the card form.
Option 2: Embeddable card element
One script tag gives your own page the same card form the Malipopay checkout uses. The fields are still hosted by the gateway, so card data never touches your page.
Step 1. On your server, create the payment with POST /api/v1/payment/card/intent (as above) and pass the reference to your page. Never put your apiToken in a browser.
Step 2. On your page, mount the element with that reference:
<!-- UAT: https://demo.malipopay.co.tz/embed/card.js -->
<script src="https://app.malipopay.co.tz/embed/card.js"></script>
<div id="card"></div>
<script>
Malipopay.card.mount({
reference: 'MU00312',
container: '#card',
accent: '#0055e2',
labels: { pay: 'Pay TZS 25,000' },
onReady: function (e) {
console.log('Card form ready for', e.reference);
},
onPaid: function (e) {
// e.payment is the verify payload. Confirm on your server before fulfilling.
window.location.href = '/thanks?ref=' + encodeURIComponent(e.reference);
},
onFailed: function (e) {
console.log('Card not accepted:', e.message);
},
});
</script>
The element talks to the API of the host it was loaded from: the copy on demo.malipopay.co.tz calls UAT, the copy on app.malipopay.co.tz calls production. Load it from the environment your reference was created in.
Step 3. Confirm on your server. The browser is not proof of payment. Fulfil the order on the payment.confirmed webhook, or after reading GET /api/v1/payment/verify/{reference} from your server and seeing status: "SUCCESSFUL".
What happens after the payer presses Pay
- No 3D Secure for the card: the element captures through
POST /api/v1/payment/card/complete/{reference}and callsonPaidon the same page. - 3D Secure challenge: the page leaves for the issuer's screen. Afterwards Malipopay captures and sends the payer back to the page they started on with
?ref={reference}&card=successful(orcard=declined/card=failedwithcode=). Whenmount()runs again on that page with the same reference, it reads the outcome, removes it from the address bar, and callsonPaidoronFailed. So always mount the element on the page the payer returns to.
The return address is tied to your page's own origin (the browser's Origin header on the prepare call), so a reference cannot be used to send a payer to any other site.
Options
| Option | Default | Meaning |
|---|---|---|
reference | required | From POST /api/v1/payment/card/intent |
container | required | An element, or a CSS selector for one |
apiBase | By the host the script came from | Override the Malipopay API base URL |
accent | #a80754 | Colour of the Pay button |
labels | English | Any of name, number, month, year, csc, pay, foot |
locale | en_US | Locale of the gateway-hosted fields. The gateway does not offer Swahili |
country | TZA | Billing country passed to 3D Secure |
onReady, onPaid, onFailed | none | Callbacks. onPaid receives { reference, payment }, onFailed receives { reference, message } |
mount() returns { reference, destroy() }. The element pays the whole outstanding amount; it does not take part payments.
Option 3: Direct API
Build your own client when you need full control, for example a native mobile app. The card must still be captured by the gateway, never by your code:
- On the web, load the gateway's scripts from the
gatewayBasethatinitSessionreturns:{gatewayBase}/form/version/77/merchant/{merchantId}/session.jsfor the hosted fields and{gatewayBase}/static/threeDS/1.3.0/three-ds.min.jsfor 3D Secure. Configurethree-ds.jswith themerchantId,sessionIdandwsVersion(77) from the prepare response. - In a mobile app, use Mastercard's Gateway Mobile SDK and send
channel: "app"on prepare. The app renders the 3D Secure challenge itself and then callscomplete; there is no browser return. The SDK is not on any public package registry; ask Malipopay for it. It reaches the gateway by region rather than by URL:MTFfor the test host andASIA_PACIFICfor the live host. When you update the session from the SDK, setsourceOfFunds.typetoCARD, or the capture is refused with "Source of funds type must be provided".
The call order is always: intent (server) then initSession, prepare, card entry and authentication on the client, then complete (or the browser return), then confirm on your server.
Authentication
| Endpoint | Called from | Credential |
|---|---|---|
POST /api/v1/payment/card/intent | Your server | apiToken header. (The dashboard calls it with a project header plus a signed-in member's Authorization: Bearer JWT.) A bare project header with no JWT is refused |
POST /api/v1/payment/card/initSession | Payer browser or app | None, but reference is required and must be a collection that can still be paid |
POST /api/v1/payment/card/prepare/{session}/{reference} | Payer browser or app | None. Guarded by the reference, and the amount is read from the payment |
POST /api/v1/payment/card/complete/{reference} | Payer browser or app | None. The outcome is read from the gateway |
GET or POST /api/v1/payment/card/3ds/return/{reference} | The card gateway, through the payer's browser | None. You never call it |
GET /api/v1/payment/verify/{reference} | Browser or your server | None needed. From your server, send your apiToken as you do on every call |
Server calls use your project's apiToken, the same key you use for mobile money. The Malipopay dashboard calls intent as the signed-in user (an Authorization: Bearer JWT plus a project header); an integration does not need that. A project header on its own proves nothing about the caller and is refused (401 or 403).
The browser-side endpoints need no key because nothing they accept can move money to the wrong place: the amount comes from the payment row, the capture decision comes from the gateway, a session is only opened for a payment that can still be paid, and a paid, cancelled or deleted reference refuses a new attempt.
Endpoints
Create a card payment
POST /api/v1/payment/card/intent
apiToken: YOUR_API_KEY
Content-Type: application/json
{
"amount": 25000,
"currency": "TZS",
"description": "Order 42",
"customerReference": "ORDER-42",
"customer": {
"firstname": "Amina",
"lastname": "Juma",
"email": "amina@example.com",
"phoneNumber": "255712345678"
}
}
| Field | Required | Notes |
|---|---|---|
amount | Yes | A positive number, in the currency's whole units. The payer is asked for this amount |
currency | No | ISO code. Defaults to TZS |
description | No | Shown on the payment and to the gateway. Cut at 250 characters |
customerReference | No | Your own id for the order. Echoed on the webhook. Cut at 100 characters |
customer | No | Payer details, when you have them. A card payer may have no phone number. A valid email is passed to the gateway for 3D Secure |
Response 201 Created
{
"success": true,
"message": "card payment created",
"data": {
"reference": "MU00312",
"amount": 25000,
"currency": "TZS",
"status": "PROCESSING",
"checkoutUrl": "https://app.malipopay.co.tz/ref?MU00312"
}
}
Your project's limits apply exactly as they do to mobile money. There is no test-recipient whitelist for cards, because a card payment has no phone number to whitelist.
| HTTP | message (examples) | Meaning |
|---|---|---|
| 400 | amount must be a positive number | Missing, zero or negative amount |
| 402 | This transaction exceeds the per-transaction limit of 50,000 TZS. | Over your per-transaction cap |
| 402 | Test Sandbox daily limit (100,000 TZS) reached. It resets at midnight. | UAT daily cap, counted with your other collections |
| 402 | Starter daily limit (100,000 TZS) reached. Complete go-live approval to raise your limits. | Production account not yet go-live approved |
| 402 | Daily collection limit (…) reached. | Your approved daily limit |
| 403 | apiToken header / project Id header is empty! | No apiToken header |
| 401 or 403 | (authentication error) | A project header without a signed-in member's JWT |
| 403 | Collection is disabled for this account. | Collections are switched off for the project |
| 403 | This account is suspended. Contact support. | Account suspended |
Every error body has the same shape:
{
"success": false,
"message": "amount must be a positive number"
}
Open a hosted session
POST /api/v1/payment/card/initSession
Content-Type: application/json
{
"reference": "MU00312"
}
The reference is required. It is how Malipopay picks your project's gateway account and the right gateway (test or live), and a session is only opened for a collection that can still be paid by card. No session is opened on a refusal.
Response 200 OK (a real UAT response, with the session key removed)
{
"success": true,
"message": "Card session created",
"data": {
"merchant": "993455000092",
"result": "SUCCESS",
"session": {
"aes256Key": "(removed)",
"authenticationLimit": 25,
"id": "SESSION0002943025168K29748889M7",
"updateStatus": "NO_UPDATE",
"version": "77c6c1f601"
}
},
"merchantId": "993455000092",
"gatewayBase": "https://test-nmbbank.mtf.gateway.mastercard.com",
"env": "TEST"
}
| Field | Use |
|---|---|
data.session.id | The session to bind on prepare and to configure session.js with |
merchantId | The gateway merchant id session.js and three-ds.js must be configured with |
gatewayBase | Load the gateway scripts from this host. A session only exists on the host that created it; loading the scripts from the other host gives "Form Session not found" |
env | TEST on the test gateway, PROD on the live one |
| HTTP | message | Meaning |
|---|---|---|
| 400 | A payment reference is required to start a card session | No reference in the body |
| 400 | Payment not found | No payment has that reference, or it was deleted |
| 400 | Only collections can be paid by card | The reference is a payout |
| 400 | This payment request was cancelled. Ask the business for a new one. | The merchant cancelled the payment request |
| 400 | This payment has already been paid | Nothing is outstanding |
A payment that FAILED, EXPIRED or was refused on the phone (USER_REJECTED) can still be paid by card: that is exactly when a payer is offered "pay by card instead".
Bind the payment to the session
POST /api/v1/payment/card/prepare/{session}/{reference}
Content-Type: application/json
{
"returnUrl": "https://shop.example.com/pay/ORDER-42",
"channel": "browser"
}
| Field | Required | Notes |
|---|---|---|
returnUrl | No | The page the payer is on. After a 3D Secure challenge they come back here. From your own site it must be on the same origin the browser reports in its Origin header; anything else is replaced by your origin's root. For Malipopay's own pages, production accepts only *.malipopay.co.tz addresses. Defaults to the Referer |
channel | No | browser (default) for three-ds.js on a web page, app for the Mobile SDK. app sets no return address |
amount | No | Pay only part of what is outstanding. Refused unless part payments are switched on (see Part payments). Absent means the whole outstanding amount |
Response 200 OK
{
"success": true,
"message": "card session prepared",
"data": {
"reference": "MU00312",
"sessionId": "SESSION0002943025168K29748889M7",
"orderId": "MU00312-C1",
"authTransactionId": "MU00312-C1-A",
"attempt": 1,
"amount": 25000,
"outstanding": 25000,
"currency": "TZS",
"merchantId": "993455000092",
"gatewayBase": "https://test-nmbbank.mtf.gateway.mastercard.com",
"wsVersion": 77,
"redirectResponseUrl": "https://core-uat.malipopay.co.tz/api/v1/payment/card/3ds/return/MU00312?order=MU00312-C1"
}
}
Each prepare opens a new attempt: its own gateway order, {reference}-C{n}, with the authentication transaction {reference}-C{n}-A and the payment transaction {reference}-C{n}-P. That is what makes a declined card retryable: the next prepare opens -C2 on the same reference. Pass orderId and authTransactionId to three-ds.js.
| HTTP | message | Meaning |
|---|---|---|
| 400 | reference and session are required | A path segment is missing |
| 400 | Payment not found | No payment has that reference |
| 400 | Only collections can be paid by card | The reference is a payout |
| 400 | This payment has already been paid | Nothing is outstanding. Do not start another attempt |
| 400 | This payment request was cancelled. Ask the business for a new one. | The merchant cancelled the payment request |
| 400 | A card payment on this reference is still being confirmed with the bank. Please wait a minute, then check again before paying. | An earlier capture got no answer from the gateway and could not be settled yet. Do not open another attempt: poll verify (see Unconfirmed captures) |
| 400 | We could not start a secure card session. Please try again. or The secure card session has expired. Please enter the card again. | The gateway refused to update the session. Start again with a new initSession |
| 400 | Paying part of a payment by card is not available yet. Pay the outstanding TZS 25,000, or pay a part with mobile money. | amount sent while part payments are off |
| 400 | Amount must be a whole number | Part payments on, fractional amount |
| 400 | Amount cannot exceed the outstanding TZS 25,000 | Part payments on, amount too big |
| 400 | Amount must be at least TZS 100 | Part payments on, amount under the floor and not the last part |
Collect the card and authenticate (client side)
This step runs entirely between the payer's browser (or app) and the gateway. With the hosted fields bound, PaymentSession.updateSessionFromForm('card') writes the card into the session. Then ThreeDS.initiateAuthentication(orderId, authTransactionId, callback):
gatewayRecommendationother thanPROCEED: stop, and ask for another card.- Authentication available (
AUTHENTICATION_AVAILABLEwith a version other thanNONE): callThreeDS.authenticatePayer(orderId, authTransactionId, callback, { fullScreenRedirect: true }). The page leaves for the issuer. - Anything else (for example
AUTHENTICATION_NOT_SUPPORTED, which every card answers on UAT): callcompletenow.
Capture
POST /api/v1/payment/card/complete/{reference}
Content-Type: application/json
{
"session": "SESSION0002943025168K29748889M7",
"order": "MU00312-C1"
}
| Field | Required | Notes |
|---|---|---|
session | Recommended | The session you hold. A session that belongs to another attempt is refused |
order | No | The attempt's orderId. Without it, the attempt that uses session is completed |
complete always answers HTTP 200, so a decline is never mistaken for a network error. Branch on data.status. The top-level success is true only when data.status is SUCCESSFUL.
Paid in full
{
"success": true,
"message": "Payment successful",
"data": {
"reference": "MU00312",
"orderId": "MU00312-C1",
"status": "SUCCESSFUL",
"message": "Payment successful",
"gatewayCode": "APPROVED",
"authenticationStatus": "AUTHENTICATION_NOT_SUPPORTED",
"capturedAmount": 25000,
"paymentStatus": "SUCCESSFUL",
"paidAmount": 25000,
"outstanding": 0
}
}
Declined by the bank
{
"success": false,
"message": "The card was declined by your bank.",
"data": {
"reference": "MU00312",
"orderId": "MU00312-C1",
"status": "DECLINED",
"message": "The card was declined by your bank.",
"gatewayCode": "DECLINED",
"authenticationStatus": "AUTHENTICATION_NOT_SUPPORTED",
"paymentStatus": "PROCESSING",
"paidAmount": 0,
"outstanding": 25000
}
}
Already completed (a repeat call, from anywhere, never captures twice)
{
"success": true,
"message": "Payment already confirmed",
"data": {
"reference": "MU00312",
"orderId": "MU00312-C1",
"status": "SUCCESSFUL",
"message": "Payment already confirmed",
"alreadyPaid": true,
"capturedAmount": 25000,
"paymentStatus": "SUCCESSFUL",
"paidAmount": 25000,
"outstanding": 0
}
}
Another capture is in flight (for example the 3D Secure return and your page arrived together)
{
"success": false,
"message": "Payment is being completed",
"data": {
"reference": "MU00312",
"orderId": "MU00312-C1",
"status": "PENDING",
"message": "Payment is being completed"
}
}
The gateway did not answer the capture (a timeout or dropped connection; the card may or may not have been charged)
{
"success": false,
"message": "We are confirming this payment with your bank. Please do not pay again: this page will update.",
"data": {
"reference": "MU00312",
"orderId": "MU00312-C1",
"status": "PENDING",
"message": "We are confirming this payment with your bank. Please do not pay again: this page will update.",
"gatewayCode": "UNCONFIRMED",
"authenticationStatus": "AUTHENTICATION_NOT_SUPPORTED"
}
}
On PENDING, poll GET /api/v1/payment/verify/{reference} every few seconds until status is SUCCESSFUL, rather than calling complete again. Never start a new card attempt while a capture is unconfirmed: that is how a payer gets charged twice.
data.status | Meaning | What to do |
|---|---|---|
SUCCESSFUL | This attempt's money was captured | Read paymentStatus and outstanding for the payment as a whole |
DECLINED | The gateway or the bank refused the card | Show message. Prepare a new attempt for another try |
PENDING | Authentication still in progress, another capture holds the lock, or the capture got no answer (gatewayCode: UNCONFIRMED) | Poll verify. Do not re-charge |
FAILED | Not captured and not a bank decline: authentication failed, the session does not match, the payment does not exist or was cancelled (PAYMENT_CANCELLED), or part was paid elsewhere meanwhile | Show message. Prepare a new attempt, unless the payment was cancelled |
gatewayCode values and the payer-facing message Malipopay sends with them:
gatewayCode | message |
|---|---|
APPROVED | Payment successful (or Part payment received) |
DECLINED, DECLINED_DO_NOT_CONTACT | The card was declined by your bank. |
INSUFFICIENT_FUNDS | The card was declined for insufficient funds. |
EXPIRED_CARD | The card has expired. |
INVALID_CSC | The security code was not accepted. |
BLOCKED | This card could not be accepted for this payment. Try another card or pay with mobile money. The gateway's own risk rules refused it before the bank was asked |
ERROR | The gateway refused the request itself (nothing was charged). The payer is never shown the gateway's raw text. A card network your account is not set up for (American Express or Maestro on UAT): This type of card is not accepted here. Try another card, or pay with mobile money. An expired session: The secure card session has expired. Please enter the card again. Anything else: The card could not be processed. Try another card or pay with mobile money. |
NOT_SENT | We could not reach the card gateway, and your card has not been charged. Please try again. The gateway confirmed it never received the capture (data.status is DECLINED) |
UNCONFIRMED | We are confirming this payment with your bank. Please do not pay again: this page will update. (data.status is PENDING) |
PAYMENT_CANCELLED | This payment request was cancelled. Ask the business for a new one. The merchant cancelled the payment between prepare and capture; the card is not charged (data.status is FAILED) |
AMOUNT_EXCEEDS_OUTSTANDING | Part of this payment was paid in the meantime. Start again to pay what is left. |
| any other | The card was not accepted. Try another card or pay with mobile money. |
3D Secure return (gateway only)
POST /api/v1/payment/card/3ds/return/{reference}?order={orderId}
GET /api/v1/payment/card/3ds/return/{reference}?order={orderId}
This is the redirectResponseUrl on the session. The gateway sends the payer's browser here after the issuer challenge. You never call it. Malipopay ignores what the form says, reads the result from the gateway, captures, and answers 303 See Other:
| Where the payment started | Outcome | Redirect |
|---|---|---|
| Malipopay checkout, invoice or Pay Me page | Paid in full | https://app.malipopay.co.tz/r/{slug} (the receipt) |
| Malipopay checkout, invoice or Pay Me page | Part paid | https://app.malipopay.co.tz/ref?{reference} (the payment page, showing what is left) |
| Malipopay checkout, invoice or Pay Me page | Declined or failed | Back to the page, ?ref={reference}&card=declined&code=DECLINED (or card=failed) |
| Your own page (the element, or your own client) | Any | Back to your page, ?ref={reference}&card=successful (or declined, failed, pending), plus code=, auth= and, on a part payment, outstanding= |
The query is for display only. Never fulfil on card=successful in the address bar; confirm on your server.
Gateway notification (gateway only)
POST /api/v1/payment/card/webhook
The card gateway also reports every transaction result to this address. You never call it, and it is not your webhook: your integration hears about card payments through payment.confirmed (see Webhook below). Malipopay uses the notification only to reconcile. It never issues a PAY from it, and it credits a payment only after reading the transaction back from the gateway with its own credentials and matching the amount of the attempt. It answers 200 with what it did:
{
"success": true,
"message": "notification received",
"data": {
"action": "CONFIRMED",
"reference": "MU00312",
"orderId": "MU00312-C1"
}
}
action is one of IGNORED, UNMATCHED, ALREADY_CONFIRMED, CONFIRMED, RECORDED, MISMATCH or UNVERIFIED. A capture already credited by the page or the 3D Secure return reads ALREADY_CONFIRMED and changes nothing.
Read the payment
GET /api/v1/payment/verify/{reference}
apiToken: YOUR_API_KEY
Response 200 OK
{
"success": true,
"message": "Payment fetch successful!",
"data": {
"merchantAccountId": "Duka Instagram",
"mode": "CHARGE",
"amountType": "FLEXIBLE",
"reference": "MU00312",
"amount": 25000,
"paidAmount": 25000,
"transactionFee": 0,
"customer": {
"phoneNumber": "255712345678",
"firstname": "Amina",
"lastname": "Juma",
"mno": null
},
"description": "Order 42",
"currency": "TZS",
"status": "SUCCESSFUL",
"id": "66fa3c1e9b1d4a0012ab34cd",
"createdAt": "2026-09-30T07:15:02.114Z",
"updatedAt": "2026-09-30T07:15:41.870Z",
"shareSlug": "k7Qm2xPa",
"method": {
"type": "card",
"scheme": "MASTERCARD",
"last4": "0018"
},
"merchant": {
"name": "Duka Instagram",
"logo_url": null
}
}
}
method says how the payment was paid, and carries nothing more of the card than the network and the last four digits:
method.type | Shape | When |
|---|---|---|
card | { "type": "card", "scheme": "MASTERCARD", "last4": "0018" } | Paid by card. scheme is the card network, for example MASTERCARD, VISA or AMEX. Both are null for a card payment captured before this field existed |
mobile | { "type": "mobile", "network": "Vodacom" } | Paid by mobile money (or not paid yet) |
mixed | { "type": "mixed", "scheme": "VISA", "last4": "1111", "network": "Tigo" } | Part by card and part by mobile money |
If a card capture on the reference is unconfirmed, verify first asks the gateway what it holds and settles it: an approved capture is credited (once), and one the gateway refused or never received is closed. So polling verify is how an unconfirmed capture resolves.
The Malipopay receipt prints this as, for example, Card (Mastercard •••• 0018). An unknown reference answers 404 with Payment not found for 'MU00312'.
Webhook
A captured card payment sends payment.confirmed to every webhook URL you have registered, signed with X-Malipopay-Signature exactly like mobile money. See Webhooks for setup and signature checks.
{
"timestamp": "20260930101541",
"reference": "MU00312",
"customerReference": "ORDER-42",
"amount": 25000,
"merchantAccountId": "Duka Instagram",
"status": "SUCCESSFUL",
"type": "CHARGE",
"customer": {
"firstname": "Amina",
"lastname": "Juma",
"email": "amina@example.com",
"phoneNumber": "255712345678"
},
"transactionId": "MU00312-C1-P",
"payloadSignature": "b875460229adc88cef4bd9904b0b06ba4b2cb4...",
"event": "payment.confirmed",
"service": "Collection",
"message": "The payment was received.",
"code": 91000
}
| Field | For a card payment |
|---|---|
amount | The total paid on the reference so far. On a part payment this is the running total, not the part |
status | SUCCESSFUL when nothing is owed, PARTIAL while something is |
code | 91000 paid in full, 91001 part paid |
transactionId | The gateway payment transaction, {reference}-C{n}-P |
customer | What you sent on the intent |
A declined card sends no webhook. The payment stays open (PROCESSING) so the payer can try another card or pay with mobile money, and payment.failed is not sent for a card decline. Read the attempt's outcome from complete or from the redirect, and the payment's state from verify.
The capture is also reported to Malipopay by the gateway itself (Gateway notification). It changes nothing you receive, and a payment is never confirmed twice.
Idempotency and retries
- One capture per attempt, once. The payer's page, the 3D Secure return and the gateway's notification can all report the same capture. The first one credits the payment; the rest answer
alreadyPaid: trueand credit nothing, send no second webhook and post nothing twice. - Concurrent completions are locked. While one capture is in flight, a second
completeanswersPENDING. The lock releases itself after 90 seconds if a process dies. - A paid reference refuses new attempts.
prepareanswers400 This payment has already been paid. - Retry after a decline by calling
initSessionandprepareagain on the same reference. Each prepare opens a new attempt (-C2,-C3, and so on); nothing needs cleaning up. The element and the checkout do this for the payer automatically. - Do not call
completein a loop. OnPENDING, pollGET /api/v1/payment/verify/{reference}instead. - Unconfirmed captures block new attempts. If a capture got no answer,
preparerefuses a new attempt ("still being confirmed with the bank") until verify has settled it with the gateway. This is deliberate: it stops a payer who was in fact charged from being charged again. - Double-clicking Pay is safe. The element ignores a second press while one is running, and the server lock covers any client that does not.
- Your webhook handler must be idempotent anyway: key on
referenceplusstatus, as you do for mobile money.
Unconfirmed captures
Rarely, the gateway does not answer a capture (a timeout or a dropped connection). The payer may or may not have been charged, so Malipopay never guesses:
completeanswersdata.status: "PENDING",gatewayCode: "UNCONFIRMED", and tells the payer not to pay again.- Malipopay asks the gateway what it holds. If the gateway has no record, the attempt closes as
NOT_SENT(not charged, safe to try again). If it approved, the money is credited once andpayment.confirmedis sent. If it refused, the attempt closes as a decline. GET /api/v1/payment/verify/{reference}triggers that check, so poll it every few seconds. The element and the checkout already do.- While a capture is unconfirmed,
preparerefuses a new attempt on the same reference.
Your integration should: poll verify, keep the payer on a waiting message, and never create a new card payment or a new attempt for the same order until verify says SUCCESSFUL or the attempt is closed.
Raw card details are refused
Malipopay never accepts card numbers. POST /api/v1/payment and PUT /api/v1/payment (on /api/v1 and /api/v2) refuse any request that carries cardNumber, cvc, cvv, securityCode, expiryMonth, expiryYear or pan, at the top level, under paymentMethodDetails, or under card. The request is answered before it is logged or stored:
{
"success": false,
"code": "CARD_DETAILS_NOT_ACCEPTED",
"message": "Card details must not be sent to this API. Create the payment, then let the payer enter the card on the Malipopay checkout page (or the card element on your site).",
"details": "Remove: cardNumber, cvc"
}
Create the payment with POST /api/v1/payment/card/intent, then let the payer type the card into the gateway-hosted fields.
Part payments
A reference with a FLEXIBLE amount can be paid in parts by mobile money. By card it is switched off, behind the server flag CARD_PART_PAYMENTS_ENABLED, which is not set on UAT or on production today. While it is off:
- a card attempt always pays the whole outstanding amount;
preparewith anamountthat is not the whole outstanding answers400 Paying part of a payment by card is not available yet...;- retrying a declined card is unaffected, because a retry pays everything outstanding in one capture.
It is off because the ledger does not yet post each card part separately: a second card part would reach the payer's card but not your wallet. It will be switched on once that is fixed. When it is on, each part is a whole number of at least TZS 100 (the last part may be smaller), no more than what is outstanding, and the payment moves to PARTIAL and then SUCCESSFUL, with a payment.confirmed for each part.
Refunds
Card refunds and voids are not available through Malipopay yet. There is no refund endpoint for card payments, and the platform does not send a refund or reversal to the card gateway. If a card payment must be returned, contact Malipopay support with the reference.
Security notes
- Card numbers never reach your page, your server or Malipopay. Your PCI scope is the smallest there is. Malipopay's API refuses card fields outright (
CARD_DETAILS_NOT_ACCEPTED). - On production, a 3D Secure return to a Malipopay page is only ever sent to a
*.malipopay.co.tzaddress; a return to your own site stays on your site's origin. - Never trust the browser for the outcome. Confirm with the webhook or with verify from your server.
- Keep your
apiTokenon your server.POST /api/v1/payment/card/intentis the only card call that needs it.