Mobile Money checkout
A complete checkout block. Choose a method, enter a number, approve on the phone, then see a receipt. It handles the waiting, the polling, the timeout and the retry, and never talks to a payment provider itself.
Preview
Paiement
Installation
npx shadcn@latest add @mpui-kit/momo-checkoutRequires the @mpui-kit namespace. See Getting started.
Usage
import { MomoCheckout } from "@/components/mpui-kit/momo-checkout"
// Inside <MpuiKitProvider country={cm}>
<MomoCheckout
amount={25000}
summary="Studio in Bastos, 1 night"
onPay={async ({ methodId, phone, amount, currency, attempt, signal }) => {
// Call YOUR backend. MPUI-KIT never talks to a payment provider.
const res = await fetch("/api/pay", {
method: "POST",
headers: { "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({ methodId, phone, amount, currency, attempt }),
signal,
})
if (!res.ok) return { status: "failed", reason: "gateway_error" }
const { reference, ussdCode } = await res.json()
return { status: "pending", reference, ussdCode }
}}
onCheckStatus={async (reference, { signal }) => {
const res = await fetch(`/api/pay/${reference}`, { signal })
return res.json() // { status: "pending" | "success" | "failed", reason? }
}}
onSuccess={(receipt) => console.log("paid", receipt.reference)}
failureMessages={{ insufficient_funds: "Your balance is too low." }}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| amount* | number | — | Amount to pay, in CFA francs. |
| onPay* | (request: PaymentRequest) => Promise<PayResult> | — | Starts the payment on your backend. Return pending to have onCheckStatus polled, success if it is already paid, or failed. |
| onCheckStatus | (reference: string, ctx: { attempt, signal }) => Promise<StatusResult> | — | Asks your backend for the result of a pending payment, every pollIntervalMs. Without it, a pending payment simply waits for the timeout. |
| timeoutMs | number | 120000 | How long to wait for approval before timing out. |
| pollIntervalMs | number | 3000 | Time between status checks. |
| currency | "XAF" | "XOF" | country's currency | CFA franc zone. |
| title | string | “Checkout” | Heading. |
| summary | ReactNode | — | What is being paid for, shown under the heading. |
| methods | PaymentMethod[] | defaultPaymentMethods(country) | Payment methods to offer. |
| panels | Record<string, MethodPanel> | — | Beta. Your own panel for a payment method, by method id: where you host your provider’s card fields or PayPal buttons. The panel hands back an opaque token, which reaches onPay as payload. Card details never enter MPUI-KIT. See the card and PayPal guide. |
| methodIcons | Record<string, ReactNode> | — | Your own icons by method id. MPUI-KIT ships no brand logos. |
| submitLabel | string | “Pay {amount}” | Text for the Pay button. |
| submitDisabled | boolean | — | Disables the Pay button, for example until your terms are accepted. |
| footer | ReactNode | — | Shown under the Pay button, for example a link to your terms. |
| receiptDetails | { label: string; value: ReactNode }[] | — | Extra rows on the receipt, for example what was bought. |
| failureMessages | Record<string, string> | — | Customer-facing messages for the failure reasons your backend returns. A reason that is not listed shows a generic message. Raw backend or error text is never shown. |
| onSuccess | (receipt: PaymentReceipt) => void | — | Called once when a payment succeeds. |
| onFailure | (reason?: string) => void | — | Called once when a payment fails, with your backend's reason. Not called on timeout. |
| onDone | () => void | — | Called when the user presses Done on the receipt. The checkout then starts over. |
| country | CountryConfig | MpuiKitProvider's country | Country to use. |
| locale | "fr" | "en" | MpuiKitProvider's locale, then “fr” | Language. |
| operatorLogos | Record<string, ReactNode> | — | Your own operator logos by id, shown instead of the color dot. |
| className | string | — | Styles the root of the checkout. |
| classNames | { title, summary, total, amount, form, submit, picker, prompt, cancel, receipt, failure, footer } | — | Class names for parts of the checkout. Each part also has a data-slot attribute, such as momo-checkout-submit. |
About the demo
The preview above talks to a fake backend that makes no network calls: nothing is dialled or sent to an operator. Pick what the fake user does on their phone, then pay with Mobile Money. Card and cash settle straight away. The dial code shown (*123# for MTN, #150# for Orange) matches each operator's real one, only to make the demo realistic.
The backend contract
You give the block two async functions. That is the whole integration, so it works with any provider or aggregator.
// What onPay receives
interface PaymentRequest {
methodId: string // "mtn", "orange", "card", "cash"...
phone?: string // E.164, for Mobile Money
payload?: unknown // an opaque token from a method panel: a card token, a PayPal order id
amount: number
currency: "XAF" | "XOF"
attempt: number // 1, then 2, 3... after each retry
signal: AbortSignal // aborted on cancel, timeout or unmount
}
// What onPay returns
type PayResult =
| { status: "success"; reference: string } // done already (cash, or a backend that waits)
| { status: "pending"; reference: string; ussdCode?: string } // now polling onCheckStatus
| { status: "failed"; reason?: string }
// What onCheckStatus returns
type StatusResult =
| { status: "pending" }
| { status: "success"; reference?: string }
| { status: "failed"; reason?: string }Cards, PayPal and other methods (beta)
Add any method with a kind of "other" (or "card"), then give it a panel to host your provider's own fields or buttons. Card details never enter MPUI-KIT: the panel returns a token and the checkout passes it to onPay as payload. Read the card and PayPal guide.
States
| State | What happens |
|---|---|
| idle | Choose a method and, for Mobile Money, enter a number. Pay validates first. |
| awaiting_approval | onPay is running, then onCheckStatus is polled. Mobile Money shows the USSD prompt with a countdown. Cancel goes back to idle. |
| success | Shows the receipt. Done starts over. |
| failed | Try again re-runs onPay with the same details, or change the payment method. |
| timeout | No approval before timeoutMs. Same actions as failed, plus a warning not to pay twice. |
useMomoCheckout
Want your own UI? The state machine and the async flow are a separate hook. It calls onPay, polls onCheckStatus, times out and cleans up. Results that arrive after a cancel, retry or timeout are ignored, so a slow response can never change the wrong attempt.
import { useMomoCheckout } from "@/hooks/mpui-kit/use-momo-checkout"
const { state, pay, retry, cancel, reset } = useMomoCheckout({
amount: 25000,
currency: "XAF",
onPay,
onCheckStatus,
timeoutMs: 120_000, // default 2 minutes
pollIntervalMs: 3_000, // default 3 seconds
})
pay({ methodId: "mtn", phone: "+237651234567" })
// state.status: "idle" | "awaiting_approval" | "success" | "failed" | "timeout"ReceiptCard
The receipt is its own component, installed with the checkout. Use it on a confirmation page too.
| Prop | Type | Default | Description |
|---|---|---|---|
| receipt* | PaymentReceipt | — | { reference, amount, currency, methodId, phone?, paidAt }. MomoCheckout builds it for you. |
| details | { label: string; value: ReactNode }[] | — | Extra rows after the payment details. |
| onDone | () => void | — | Adds a Done button that calls this. |
| country / locale | CountryConfig / “fr” | “en” | from MpuiKitProvider | Used to name the method and format the phone and date. |
| classNames | { icon, title, list, done } | — | Class names for parts of the card. The checkmark uses your --success color if you define one, otherwise --primary. |
| ...props | ComponentProps<'section'> | — | Other section props. A ref and tabIndex let a parent move focus to it. |
Notes
- Never trust the browser for the outcome. The receipt comes from what your backend reports. Confirm payments on your server, for example with the provider's webhook, before you deliver anything.
- Avoid double charges. Use
attemptor an idempotency key so a retry cannot create a second charge. After a timeout the block warns people who already approved not to pay again. - Cancel does not cancel on your backend. It stops waiting and aborts the request signal. Cancel the payment on your side if your provider supports it.
- Slow networks. A few failed status checks in a row do not fail the payment, because the user may already have approved it. After five in a row it does.
- Failure messages. Raw error text and unknown reasons are never shown to customers. Map your reasons with
failureMessages. - Accessibility. Pressing Pay with something missing shows the error and moves focus to it. Focus moves to the receipt or the error when the result arrives, and back to the method cards after a reset.