Card and PayPal (beta)
Add a card form, PayPal or any other method to the checkout by giving it a panel: a place where your payment provider's own fields or buttons live.
The rule: card details never enter MPUI-KIT
MPUI-KIT does not build card fields. Your provider's hosted fields (Stripe's Payment Element, PayPal's buttons) keep the card details in their own iframe. When the customer presses Pay, the panel asks the provider for an opaque token and the checkout hands that token to your onPay as payload. It is never shown on the receipt.
This keeps your site out of the heaviest part of card-industry (PCI DSS) compliance. Your provider can tell you which self-assessment applies to you.
Try it
This is Stripe's real Payment Element and PayPal's real Buttons, in test mode, using each provider's own public test credentials so there is nothing to configure. Nothing is ever charged. Try Stripe's test card 4242 4242 4242 4242, any future expiry, any CVC and postal code. Watch the right side: only a token or an order id ever reaches this page, never card details.
The PayPal button here creates its order in the browser, from a fixed amount, only because this demo has no server. Do not do that in your app: create the order on your server, from your own price, or the amount can be changed before it reaches PayPal. See the example below for the right way.
Checkout
What your onPay receives
Choose a method and pay to see the request.
Only the token or order id from the panel reaches your code, never card details.
Set it up
Add the method, then map its id to a panel. A method of kind "other" takes any label and description, and methodIcons takes your own logo.
import { MomoCheckout } from "@/components/mpui-kit/momo-checkout"
import { defaultPaymentMethods } from "@/lib/mpui-kit/payment-methods"
const methods = [
...defaultPaymentMethods(cm, { card: false, cash: false }), // MTN, Orange...
{ id: "card", kind: "card" },
{ id: "paypal", kind: "other", label: "PayPal", description: "Pay with your PayPal account" },
]
<MomoCheckout
amount={25000}
methods={methods}
panels={{
card: { component: StripeCardPanel },
paypal: { component: PayPalPanel, submit: "panel" },
}}
onPay={async ({ methodId, phone, payload }) => {
// Your server creates the charge. It never trusts an amount sent from the browser.
const res = await fetch("/api/pay", {
method: "POST",
body: JSON.stringify({ methodId, phone, payload }),
})
if (!res.ok) return { status: "failed", reason: "gateway_error" }
const { reference } = await res.json()
return { status: "success", reference } // or "pending", then onCheckStatus
}}
/>The panel contract
A panel is a React component. It tells the checkout when it is complete with setReady, and gives it the function that produces the token with setCollect. Until the panel is ready, pressing Pay asks the customer to finish the details and pays nothing. If collecting fails, nothing is paid and the panel's own message (or a generic one) is shown.
interface MethodPanelProps {
method: PaymentMethod
amount: number
currency: "XAF" | "XOF"
disabled: boolean // true while the token is being made
setReady(ready: boolean): void // "the fields are complete enough to pay"
setCollect(fn: (() => Promise<unknown>) | null): void // runs when Pay is pressed, returns the token
submit(payload?: unknown): void // for a panel that pays by itself (submit: "panel")
setError(message: string | undefined): void // shown under the panel
}
interface MethodPanel {
component: ComponentType<MethodPanelProps>
submit?: "checkout" | "panel" // "checkout" (default) uses the Pay button
}Example: Stripe Elements
A card panel with Stripe's Payment Element, the same one used in the demo above (with your own key in place of Stripe's test one, and your real amount and currency).
"use client"
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js"
import { loadStripe } from "@stripe/stripe-js"
import { useEffect } from "react"
import type { MethodPanelProps } from "@/components/mpui-kit/method-panel"
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)
function Fields({ disabled, setReady, setCollect, setError }: MethodPanelProps) {
const stripe = useStripe()
const elements = useElements()
useEffect(() => {
if (!stripe || !elements) return
setCollect(async () => {
const { error: submitError } = await elements.submit()
if (submitError) {
setError(submitError.message)
throw submitError
}
const { error, confirmationToken } = await stripe.createConfirmationToken({ elements })
if (error) {
setError(error.message)
throw error
}
// Only this id leaves the panel. Your server confirms the payment with it.
return { confirmationTokenId: confirmationToken.id }
})
return () => setCollect(null)
}, [stripe, elements, setCollect, setError])
return (
<PaymentElement
onChange={(event) => setReady(event.complete)}
options={{ readOnly: disabled }}
/>
)
}
export function StripeCardPanel(props: MethodPanelProps) {
return (
<Elements
stripe={stripePromise}
options={{ mode: "payment", amount: 1000, currency: "usd" }} // your price, converted
>
<Fields {...props} />
</Elements>
)
}// On your server, for the card flow
// 1. Read the price from YOUR data. Ignore any amount sent by the browser.
// 2. Create the payment with the token from payload, and confirm it.
// 3. Answer { status: "success", reference } or { status: "failed", reason }.
// 4. Also handle the provider's webhook: that is the source of truth.Example: PayPal buttons
PayPal's buttons start the payment themselves, so this panel is registered with submit: "panel" to hide the Pay button. Unlike the demo above, this creates the order on your server — the right way to do it, since the browser never gets to say what the price is.
"use client"
import { PayPalButtons, PayPalScriptProvider } from "@paypal/react-paypal-js"
import type { MethodPanelProps } from "@/components/mpui-kit/method-panel"
export function PayPalPanel({ disabled, submit, setError }: MethodPanelProps) {
return (
<PayPalScriptProvider
options={{
clientId: process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID!,
currency: "USD",
components: "buttons",
}}
>
<PayPalButtons
disabled={disabled}
createOrder={async () => {
// Your server creates the order from ITS record of the price.
const res = await fetch("/api/paypal/orders", { method: "POST" })
return (await res.json()).id
}}
onApprove={async (data) => submit({ orderId: data.orderID })}
onError={() => setError("PayPal could not start. Please try again.")}
/>
</PayPalScriptProvider>
)
}
// Register it with { submit: "panel" }: PayPal's buttons are the Pay button.
// Your onPay then receives { payload: { orderId } }, and your server captures the order.Before you go live
- Use your own keys. The demo uses Stripe's and PayPal's public test credentials, which anyone can use and which only ever produce test-mode data. Set
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEYandNEXT_PUBLIC_PAYPAL_CLIENT_IDto your own, and swap in your live keys when you are ready to accept real payments. - Currency. Providers support different currencies, and not every one can charge in CFA francs (XAF or XOF). Check what yours accepts. You may need to charge in another currency, such as euros, and show that amount to the customer.
- Confirm on your server. The token proves nothing about payment. Your server creates and confirms the charge, and your provider's webhook is the source of truth.
- Never trust the amount from the browser. Read the price from your own data when you create the charge or the PayPal order.
- Logos. MPUI-KIT ships no brand logos. If you show PayPal's or a card network's, follow their brand guidelines and pass them with
methodIcons.
See also the checkout reference.