Skip to main content

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

Never send card details to Malipopay

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.

Before you start

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 URLhttps://core-uat.malipopay.co.tzhttps://core-prod.malipopay.co.tz
Dashboard and hosted checkouthttps://demo.malipopay.co.tzhttps://app.malipopay.co.tz
Embeddable elementhttps://demo.malipopay.co.tz/embed/card.jshttps://app.malipopay.co.tz/embed/card.js
Card gatewayMastercard test gateway (test-nmbbank.mtf.gateway.mastercard.com)NMB live gateway (nmbbank.gateway.mastercard.com)
Cards that workTest cards only, Mastercard only, TZS 1,000 or less to approve. See the verified listReal cards only
Money movesNoYes. The payer's card is charged
3D Secure challengeNot available. Every card answers AUTHENTICATION_NOT_SUPPORTED and is captured without a challengeShown 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​

  1. Create the payment. Your server calls POST /api/v1/payment/card/intent with your apiToken. You get back a Malipopay reference.
  2. 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.
  3. 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.
  4. Collect the card. The gateway's own session.js turns your empty input boxes into gateway-hosted fields. The payer types the card, and the fields write it straight into the session.
  5. Authenticate. The gateway's three-ds.js asks 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.
  6. 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.
  7. 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.

OptionYou buildBest for
Hosted checkout linkNothing on the front end. You redirect or send a linkInvoices, links in SMS or WhatsApp, the fastest start
Embeddable card elementOne script tag and one call on your own pageKeeping the payer on your site
Direct APIYour own client around the gateway's scripts or Mobile SDKNative apps and fully custom checkouts

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:

OutcomeWhere the payer lands
Paid in full, no challengeThe checkout itself confirms the payment, on the same page
Paid in full after a 3D Secure challengeStraight 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 authenticatedBack 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 calls onPaid on 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 (or card=declined / card=failed with code=). When mount() runs again on that page with the same reference, it reads the outcome, removes it from the address bar, and calls onPaid or onFailed. 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

OptionDefaultMeaning
referencerequiredFrom POST /api/v1/payment/card/intent
containerrequiredAn element, or a CSS selector for one
apiBaseBy the host the script came fromOverride the Malipopay API base URL
accent#a80754Colour of the Pay button
labelsEnglishAny of name, number, month, year, csc, pay, foot
localeen_USLocale of the gateway-hosted fields. The gateway does not offer Swahili
countryTZABilling country passed to 3D Secure
onReady, onPaid, onFailednoneCallbacks. 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 gatewayBase that initSession returns: {gatewayBase}/form/version/77/merchant/{merchantId}/session.js for the hosted fields and {gatewayBase}/static/threeDS/1.3.0/three-ds.min.js for 3D Secure. Configure three-ds.js with the merchantId, sessionId and wsVersion (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 calls complete; 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: MTF for the test host and ASIA_PACIFIC for the live host. When you update the session from the SDK, set sourceOfFunds.type to CARD, 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​

EndpointCalled fromCredential
POST /api/v1/payment/card/intentYour serverapiToken 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/initSessionPayer browser or appNone, 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 appNone. Guarded by the reference, and the amount is read from the payment
POST /api/v1/payment/card/complete/{reference}Payer browser or appNone. The outcome is read from the gateway
GET or POST /api/v1/payment/card/3ds/return/{reference}The card gateway, through the payer's browserNone. You never call it
GET /api/v1/payment/verify/{reference}Browser or your serverNone 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"
}
}
FieldRequiredNotes
amountYesA positive number, in the currency's whole units. The payer is asked for this amount
currencyNoISO code. Defaults to TZS
descriptionNoShown on the payment and to the gateway. Cut at 250 characters
customerReferenceNoYour own id for the order. Echoed on the webhook. Cut at 100 characters
customerNoPayer 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.

HTTPmessage (examples)Meaning
400amount must be a positive numberMissing, zero or negative amount
402This transaction exceeds the per-transaction limit of 50,000 TZS.Over your per-transaction cap
402Test Sandbox daily limit (100,000 TZS) reached. It resets at midnight.UAT daily cap, counted with your other collections
402Starter daily limit (100,000 TZS) reached. Complete go-live approval to raise your limits.Production account not yet go-live approved
402Daily collection limit (…) reached.Your approved daily limit
403apiToken header / project Id header is empty!No apiToken header
401 or 403(authentication error)A project header without a signed-in member's JWT
403Collection is disabled for this account.Collections are switched off for the project
403This 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"
}
FieldUse
data.session.idThe session to bind on prepare and to configure session.js with
merchantIdThe gateway merchant id session.js and three-ds.js must be configured with
gatewayBaseLoad 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"
envTEST on the test gateway, PROD on the live one
HTTPmessageMeaning
400A payment reference is required to start a card sessionNo reference in the body
400Payment not foundNo payment has that reference, or it was deleted
400Only collections can be paid by cardThe reference is a payout
400This payment request was cancelled. Ask the business for a new one.The merchant cancelled the payment request
400This payment has already been paidNothing 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"
}
FieldRequiredNotes
returnUrlNoThe 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
channelNobrowser (default) for three-ds.js on a web page, app for the Mobile SDK. app sets no return address
amountNoPay 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.

HTTPmessageMeaning
400reference and session are requiredA path segment is missing
400Payment not foundNo payment has that reference
400Only collections can be paid by cardThe reference is a payout
400This payment has already been paidNothing is outstanding. Do not start another attempt
400This payment request was cancelled. Ask the business for a new one.The merchant cancelled the payment request
400A 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)
400We 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
400Paying 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
400Amount must be a whole numberPart payments on, fractional amount
400Amount cannot exceed the outstanding TZS 25,000Part payments on, amount too big
400Amount must be at least TZS 100Part 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):

  • gatewayRecommendation other than PROCEED: stop, and ask for another card.
  • Authentication available (AUTHENTICATION_AVAILABLE with a version other than NONE): call ThreeDS.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): call complete now.

Capture​

POST /api/v1/payment/card/complete/{reference}
Content-Type: application/json
{
"session": "SESSION0002943025168K29748889M7",
"order": "MU00312-C1"
}
FieldRequiredNotes
sessionRecommendedThe session you hold. A session that belongs to another attempt is refused
orderNoThe 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.statusMeaningWhat to do
SUCCESSFULThis attempt's money was capturedRead paymentStatus and outstanding for the payment as a whole
DECLINEDThe gateway or the bank refused the cardShow message. Prepare a new attempt for another try
PENDINGAuthentication still in progress, another capture holds the lock, or the capture got no answer (gatewayCode: UNCONFIRMED)Poll verify. Do not re-charge
FAILEDNot 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 meanwhileShow message. Prepare a new attempt, unless the payment was cancelled

gatewayCode values and the payer-facing message Malipopay sends with them:

gatewayCodemessage
APPROVEDPayment successful (or Part payment received)
DECLINED, DECLINED_DO_NOT_CONTACTThe card was declined by your bank.
INSUFFICIENT_FUNDSThe card was declined for insufficient funds.
EXPIRED_CARDThe card has expired.
INVALID_CSCThe security code was not accepted.
BLOCKEDThis 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
ERRORThe 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_SENTWe 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)
UNCONFIRMEDWe are confirming this payment with your bank. Please do not pay again: this page will update. (data.status is PENDING)
PAYMENT_CANCELLEDThis 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_OUTSTANDINGPart of this payment was paid in the meantime. Start again to pay what is left.
any otherThe 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 startedOutcomeRedirect
Malipopay checkout, invoice or Pay Me pagePaid in fullhttps://app.malipopay.co.tz/r/{slug} (the receipt)
Malipopay checkout, invoice or Pay Me pagePart paidhttps://app.malipopay.co.tz/ref?{reference} (the payment page, showing what is left)
Malipopay checkout, invoice or Pay Me pageDeclined or failedBack to the page, ?ref={reference}&card=declined&code=DECLINED (or card=failed)
Your own page (the element, or your own client)AnyBack 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.typeShapeWhen
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
}
FieldFor a card payment
amountThe total paid on the reference so far. On a part payment this is the running total, not the part
statusSUCCESSFUL when nothing is owed, PARTIAL while something is
code91000 paid in full, 91001 part paid
transactionIdThe gateway payment transaction, {reference}-C{n}-P
customerWhat 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: true and credit nothing, send no second webhook and post nothing twice.
  • Concurrent completions are locked. While one capture is in flight, a second complete answers PENDING. The lock releases itself after 90 seconds if a process dies.
  • A paid reference refuses new attempts. prepare answers 400 This payment has already been paid.
  • Retry after a decline by calling initSession and prepare again 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 complete in a loop. On PENDING, poll GET /api/v1/payment/verify/{reference} instead.
  • Unconfirmed captures block new attempts. If a capture got no answer, prepare refuses 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 reference plus status, 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:

  1. complete answers data.status: "PENDING", gatewayCode: "UNCONFIRMED", and tells the payer not to pay again.
  2. 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 and payment.confirmed is sent. If it refused, the attempt closes as a decline.
  3. GET /api/v1/payment/verify/{reference} triggers that check, so poll it every few seconds. The element and the checkout already do.
  4. While a capture is unconfirmed, prepare refuses 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;
  • prepare with an amount that is not the whole outstanding answers 400 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.tz address; 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 apiToken on your server. POST /api/v1/payment/card/intent is the only card call that needs it.