Testing Card Payments
Card payments are tested in two places, and they are not the same kind of test:
| UAT (sandbox) | Production (live) | |
|---|---|---|
| Where | API https://core-uat.malipopay.co.tz, dashboard and checkout https://demo.malipopay.co.tz | API https://core-prod.malipopay.co.tz, dashboard and checkout https://app.malipopay.co.tz |
| Cards | Test cards only, from the table below | Real cards only |
| Money | None moves. The test gateway simulates the bank | Real money. The card is charged and the money reaches your Malipopay wallet |
| 3D Secure | Not available. No challenge screen appears | The issuer's challenge appears for enrolled cards |
Do the full scenario list on UAT first. Then, on production, run a few small real payments with your own card to prove the live path, including the 3D Secure challenge that UAT cannot show.
A test card on production is refused by the live gateway. A real card on UAT does nothing useful and puts real card details into a test system. Keep them apart.
UAT (sandbox)
UAT is a separate environment with its own dashboard. Use the API key of a project on the UAT dashboard (https://demo.malipopay.co.tz), call the UAT API base URL, and load the card element from https://demo.malipopay.co.tz/embed/card.js. A project on UAT is in test mode, so its card payments go to the Mastercard test gateway and no money moves.
Rules of the sandbox
- 3D Secure is switched off on the sandbox. Every card, including cards meant to trigger a challenge, answers
AUTHENTICATION_NOT_SUPPORTEDand is captured without a challenge. You cannot test the issuer's challenge screen on UAT. Test it on production (see Live). - The test bank decides by amount. On this sandbox an approving card approves at TZS 1,000 or less and is declined above it. The amounts in the table are the ones that were run.
- Pace your retries. The gateway's fraud engine watches how fast and how often the same card is used. A few quick payments in a row on one card (about four, or roughly one a second) and it starts refusing that card with
gatewayCode: BLOCKED("This card could not be accepted for this payment") before the test bank is even asked. Other cards keep working. Leave a few seconds between attempts, rotate between the approving cards below, and if a card is blocked, switch cards or wait about ten minutes. It is not a fault in your integration. - The UAT daily cap is TZS 100,000, counted across all your collections on UAT.
POST /api/v1/payment/card/intentanswers402when it is reached.
Test cards
Every card below was run against the UAT card gateway on 30 September 2026 and the result read back from the gateway. Use only these. Cards from Mastercard's or Visa's generic test lists mostly do not behave as published on this sandbox, because it runs on NMB's own acquirer link rather than Mastercard's emulator.
Cards that approve. Expiry 01/39, security code 100, amount TZS 1,000 or less.
| Card number | Brand | Expiry | CSC | Amounts verified to approve | Expected result |
|---|---|---|---|---|---|
5555555555000018 | Mastercard (credit) | 01/39 | 100 | TZS 100, 500, 1,000 | Approved, captured |
5506900140100305 | Mastercard (credit) | 01/39 | 100 | TZS 500, 1,000 | Approved, captured |
2222400070000005 | Mastercard | 01/39 | 100 | TZS 1,000 | Approved, captured |
5506900140100503 | Mastercard (credit) | 01/39 | 100 | TZS 1,000 | Approved, captured |
5500005555555559 | Mastercard (credit) | 01/39 | 100 | TZS 1,000 | Approved, captured |
5455031257390496 | Mastercard (credit) | 01/39 | 100 | TZS 1,000 | Approved, captured |
5455031252665454 | Mastercard (credit) | 01/39 | 100 | TZS 1,000 | Approved, captured |
5297410588409146 | Mastercard (debit) | 01/39 | 100 | TZS 1,000 | Approved, captured |
The sandbox bank declines every amount above TZS 1,000 with "05 Do not honour", on every card, including TZS 1,001 and 1,000.5. Keep UAT card payments at TZS 1,000 or less when you want an approval, and use a bigger amount when you want a decline.
Cards that decline, and how. Expiry 01/39, security code 100, TZS 1,000.
| Card number | Brand | Expiry | CSC | Gateway answer | gatewayCode | What the payer is told |
|---|---|---|---|---|---|---|
5175540000050008 | Mastercard | 01/39 | 100 | 54 Expired card | EXPIRED_CARD | The card has expired. |
5175540000050099 | Mastercard | 01/39 | 100 | 54 Expired card | EXPIRED_CARD | The card has expired. |
5555555555554444 | Mastercard | 01/39 | 100 | 05 Do not honour | DECLINED | The card was declined by your bank. |
5123450000000008 | Mastercard | 01/39 | 100 | 14 Invalid card number | DECLINED | The card was declined by your bank. |
5111111111111118 | Mastercard | 01/39 | 100 | 14 Invalid card number | DECLINED | The card was declined by your bank. |
4111111111111111 | Visa | 01/39 | 100 | 05 Do not honour (15 No such issuer at TZS 500 and 100) | DECLINED | The card was declined by your bank. |
371881634498004 | American Express | 01/39 | 1000 | Refused before the bank: no acquirer relationship for this card network | ERROR | This type of card is not accepted here. Try another card, or pay with mobile money. |
What the sandbox does not do, so do not try to test it there:
- No Visa card approves. Every Visa test card declines. American Express and Maestro are refused outright.
- The expiry date is not checked. A past expiry such as
12/25still approves. Use the two expired-card numbers above to test an expired card. - The security code is not checked. Any three digits approve. A wrong CSC cannot be tested on UAT.
- Insufficient funds, timeouts and bank system errors cannot be produced.
- 3D Secure is not available (see the rules above).
Scenario matrix
Run these in order on UAT. Each one starts from a fresh card payment unless it says otherwise. Create it from your server:
curl -X POST https://core-uat.malipopay.co.tz/api/v1/payment/card/intent \
-H "apiToken: YOUR_UAT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 1000,
"currency": "TZS",
"description": "UAT card test",
"customerReference": "UAT-CARD-001"
}'
Then pay it either on the hosted checkout (open checkoutUrl, which is on https://demo.malipopay.co.tz) or through your own page with the element loaded from https://demo.malipopay.co.tz/embed/card.js. Card details are from the test card tables.
| # | Scenario | Steps | Expected screen | Expected API result |
|---|---|---|---|---|
| 1 | Approved | Payment of TZS 1,000. Pay with 5555555555000018, 01/39, 100 | No bank screen. The checkout confirms the payment on the same page; the element calls onPaid | complete: data.status SUCCESSFUL, gatewayCode APPROVED, paymentStatus SUCCESSFUL, outstanding 0. Verify: status SUCCESSFUL, paidAmount 1000 |
| 2 | Declined by the bank (do not honour) | Payment of TZS 1,500. Pay with 5555555555000018, 01/39, 100. Or TZS 1,000 with 5555555555554444 | "The card was declined by your bank." The card form stays, ready for another card | complete: data.status DECLINED, gatewayCode DECLINED. Verify: status PROCESSING, paidAmount 0 |
| 3 | Invalid card number | TZS 1,000. Pay with 5123450000000008 | "The card was declined by your bank." | data.status DECLINED, gatewayCode DECLINED (the bank's code is 14) |
| 4 | Expired card | TZS 1,000. Pay with 5175540000050008, 01/39, 100 | "The card has expired." | data.status DECLINED, gatewayCode EXPIRED_CARD |
| 5 | Wrong security code | Not testable on UAT: the sandbox never checks the CSC and any value approves. A missing CSC is refused (the gateway requires one) | With the CSC left empty, the card form asks you to check the security code | On production the issuer checks it; a refusal answers gatewayCode INVALID_CSC, "The security code was not accepted." |
| 6 | Insufficient funds or do not honour | Insufficient funds cannot be produced on UAT. Test "do not honour" with scenario 2 | As scenario 2 | On production, gatewayCode INSUFFICIENT_FUNDS, "The card was declined for insufficient funds." |
| 7 | 3D Secure not supported (sandbox) | Any approving card | No bank or OTP screen ever appears. Payment completes on the page | complete: authenticationStatus AUTHENTICATION_NOT_SUPPORTED, then SUCCESSFUL. This is the expected sandbox behaviour, not a fault |
| 8 | Card network not accepted | TZS 1,000. Pay with the American Express card 371881634498004, CSC 1000. Then a Visa card, for example 4111111111111111 | Amex: "This type of card is not accepted here. Try another card, or pay with mobile money." (never the gateway's raw text). Visa: "The card was declined by your bank." | Amex: data.status DECLINED, gatewayCode ERROR. Visa: DECLINED / DECLINED |
| 9 | Payer abandons | Open the checkout or your page, type a card, close the tab without pressing Pay. Then open the same link again | On return the form is empty and ready. Nothing was charged | Verify: status PROCESSING, paidAmount 0. No webhook. The next prepare opens a new attempt (orderId ends in -C2) |
| 10 | Duplicate submit and idempotency | After scenario 1 succeeds, call POST /api/v1/payment/card/complete/{reference} again with the same session. Also double-click Pay on a fresh payment | Nothing new happens. One charge only | Second complete: data.status SUCCESSFUL with alreadyPaid: true. Exactly one payment.confirmed webhook. prepare on the paid reference answers 400 This payment has already been paid |
| 11 | Retry after a decline | One payment of TZS 1,000. Pay with 5123450000000008 (declines), then on the same page pay with 5555555555000018 | First: "The card was declined by your bank." Second: success | Second complete: SUCCESSFUL, orderId ends in -C2. Verify: paidAmount 1000, credited once. One webhook |
| 12 | Part payment | On a TZS 1,000 payment, call prepare with { "amount": 500 } | Not offered on the checkout or the element while part payments are off | 400 Paying part of a payment by card is not available yet. Pay the outstanding TZS 1,000, or pay a part with mobile money. Part payments by card are off on UAT and production today |
| 13 | Fraud block from rapid retries | Pay four or five times in quick succession with the same card | "This card could not be accepted for this payment. Try another card or pay with mobile money." | data.status DECLINED, gatewayCode BLOCKED. Not a bank decline: the gateway's fraud engine refused the card. Other cards keep working; the same card worked again after about 8 minutes |
| 14 | Webhook received | Register a webhook on your UAT project, then run scenario 1 | Nothing on screen | Your endpoint receives payment.confirmed with status SUCCESSFUL, code 91000, transactionId ending in -C1-P, and an X-Malipopay-Signature that verifies. Scenarios 2 to 4 send no webhook |
| 15 | Receipt names the card | After scenario 1, read GET /api/v1/payment/verify/{reference} and open the receipt | The receipt's payment method reads Card (Mastercard •••• 0018) | Verify: method { "type": "card", "scheme": "MASTERCARD", "last4": "0018" } |
| 16 | Card session refused for a payment that cannot be paid | Call POST /api/v1/payment/card/initSession with no reference, then with the reference from scenario 1 (already paid), then with a payment the merchant cancelled | No card form opens (the element says "We could not start a secure card session.") | 400: A payment reference is required to start a card session, This payment has already been paid, This payment request was cancelled. Ask the business for a new one. No session is opened |
| 17 | Payment cancelled while the payer is on the card form | Open the card form, then cancel the payment request from the dashboard, then press Pay | "This payment request was cancelled. Ask the business for a new one." | complete: data.status FAILED, gatewayCode PAYMENT_CANCELLED. The card is not charged. A new prepare answers 400 with the same sentence |
| 18 | Capture unconfirmed (UNCONFIRMED) | Cannot be triggered on purpose: it happens when the gateway does not answer the capture (a timeout). Recognise it when it happens | "We are confirming this payment with your bank. Please do not pay again: this page will update." | complete: data.status PENDING, gatewayCode UNCONFIRMED. What to do: poll GET /api/v1/payment/verify/{reference} every few seconds; verify asks the gateway and settles it. Never create a new payment or attempt for the order meanwhile; prepare refuses one ("still being confirmed with the bank") |
| 19 | Capture never reached the gateway (NOT_SENT) | Cannot be triggered on purpose. Recognise it when it happens | "We could not reach the card gateway, and your card has not been charged. Please try again." | complete: data.status DECLINED, gatewayCode NOT_SENT. The gateway confirmed it has no record, so nothing was charged and the payer may try again on the same reference |
| 20 | Raw card details refused | Send POST /api/v1/payment with paymentMethodDetails.cardNumber (any value) | Nothing on screen | 400, code CARD_DETAILS_NOT_ACCEPTED, details naming the fields to remove. Card details only ever go into the gateway-hosted fields |
For every scenario, check three things, not one: what the payer saw, what GET /api/v1/payment/verify/{reference} says, and what your webhook endpoint received.
Live (production)
On production there is no sandbox for cards. Every card payment on a production project is a real payment: the card is charged, the money moves, and it shows on the cardholder's statement. Test with a real card you own, for a small amount.
How to test on production
- Use your production project and API key, and
https://core-prod.malipopay.co.tz. Load the element fromhttps://app.malipopay.co.tz/embed/card.js. - Create a card payment for a small real amount, for example TZS 1,000. Your project's production limits apply; an account that is not yet go-live approved has the starter daily limit.
- Pay it with your own real Mastercard. Visa acceptance on live depends on NMB's acquiring setup and has not been verified yet; try a Visa card only after Malipopay confirms it is accepted, or expect it may be declined.
- Expect the 3D Secure challenge from your bank if the card is enrolled: an OTP by SMS, or an approval in your banking app. The page goes full screen to the bank and comes back on its own.
- Check the same things as on UAT: the receipt or your page shows success,
payment.confirmedreached your webhook with a valid signature, andGET /api/v1/payment/verify/{reference}showsstatus: "SUCCESSFUL"withmethod.type: "card". - Try one decline you can cause safely, for example a mistyped security code or an OTP you let expire, and confirm your page shows the message and lets the payer try again.
What only production can show
- The 3D Secure challenge and the return from it. After the challenge, a payment made on a Malipopay page lands straight on the receipt (
/r/{slug}); a payment made through the element on your page comes back to that page with?ref={reference}&card=successful. - Real issuer declines (insufficient funds, card blocked for online use, OTP failed), which the sandbox only approximates.
Refunds
There is no card refund or void in Malipopay yet. No endpoint returns card money, and the platform sends no refund or reversal to the card gateway. A production test payment is therefore not undone automatically. Keep production test amounts small, and contact Malipopay support with the reference if a card payment must be returned.
Checklist before you go live with cards
- Every UAT scenario above passes, including the webhook and the duplicate checks.
- Your server fulfils only on
payment.confirmed(or on a server-side verify), never on the browser's word. - Your webhook handler is idempotent.
- The element is mounted on the page the payer returns to after 3D Secure, with the same reference.
- One small real card payment on production succeeded end to end, with the 3D Secure challenge.