Payment widget
One script, one function call, and a checkout opens over your own page. Your customer never leaves your site, and you never write a payment form.
This is what your customer sees
The real thing, running here. Type any MTN or Airtel number and press pay. It charges nothing and talks to nothing: this is the same markup the overlay renders, wired to a timer instead of to the network.
Kigali Coffee Co
RWF 5,000
Order #1234
You will get a prompt on your phone. Approve it with your mobile money PIN.
How it fits together
Three moving parts, and only the first one is yours to write:
- Your server creates a checkout session with your app key. This is where the amount is decided.
- Your page passes the token to
XendlyPay.open(). - We draw an overlay on top of your page, take the customer’s number, prompt their phone, and call you back.
The amount is set on your server, and only there
1. Create a session
On your server, when the customer is ready to pay. Use your app key.
POST /v1/checkouts
Authorization: Bearer xpay_a3f19c2e_...
{
"amount": 5000,
"description": "Order #1234",
"reference": "1234",
"method": "dial",
"success_url": "https://yourshop.rw/orders/1234?paid=1",
"cancel_url": "https://yourshop.rw/orders/1234"
}201 Created
{
"success": true,
"payload": {
"id": "9f1c…",
"token": "cs_8Kd2…",
"url": "https://xendlypay.com/checkout/cs_8Kd2…",
"amount": 5000,
"currency": "RWF",
"mode": "live",
"status": "open",
"expires_at": "2026-09-12T18:30:00Z"
}
}| Field | ||
|---|---|---|
amount | required | Minor units. 5000 = 5,000 RWF. Minimum 100. |
description | optional | Shown to the payer in the overlay. |
reference | optional | Your own order id. Echoed on the payment and in webhooks; never shown to the payer. |
msisdn | optional | Pre-fill the number. When set, the payer cannot change it: use it when you already know who is paying. |
success_url | optional | Only used when the checkout is opened as its own page rather than in the overlay. Must be an absolute http(s) URL. |
cancel_url | optional | Where it sends them if they give up. |
expires_in | optional | Seconds. Default 30 minutes, clamped to between 2 minutes and 12 hours. |
method | optional | Which way to pay the checkout opens on: "dial" (the payer dials a short code and enters their PIN) or "prompt" (a PIN prompt on their phone, the default). The payer can still switch. Pay by dialing |
One session, one payment
open. A double-click, a refreshed tab and a replayed POST all lose the race and are shown the payment that is already running, so nobody gets prompted twice for one basket.2. Open the checkout
One call. The overlay appears above your page, at the top of the stacking order, and the customer stays exactly where they were.
<script src="https://xendlypay.com/v1/widget.js"></script>async function pay() {
// Your own endpoint, which creates the session with your app key.
const res = await fetch("/checkout", { method: "POST" });
const { token } = await res.json();
XendlyPay.open(token, {
phone: customer.phone, // optional: prefills the number
theme: "auto", // optional: "light" | "dark" | "auto"
onProcessing: () => setState("waiting for the customer"),
onPaid: (r) => confirmOrder(r.payment),
onFailed: (r) => showRetry(r.message),
onCancelled: () => setState("idle"),
onClose: () => setState("idle"), // always, last
});
}XendlyPay.open(token, opts) | Draws the overlay. Calling it again while one is open returns the handle to that one rather than stacking a second. |
phone | Optional. A number you already have for the customer (0788…, 250788… or +250788…). It fills the field and stays editable. To lock the number instead, set msisdn when you create the session. |
theme | Optional. "light" or "dark" to force one; the default, "auto", matches your page: a dark or light class or data-theme on <html> or <body>, then its color-scheme, then its background colour. |
XendlyPay.close() | Closes whatever is open. If nothing has resolved, the session is cancelled on the way out. |
| Closing | It closes by itself when the payment is over: 1.5 seconds after a success, 3 seconds after a failure (onPaid or onFailed, then onClose). Before that, the backdrop, the × and the Escape key dismiss it, and fire onCancelled then onClose. |
It is an overlay, not a popup window
window.open. That is wrong on a phone, which is where mobile money is: popups are blocked by default in several browsers, open as a whole new tab on iOS Safari so the customer leaves your page and has to find their way back, and cannot be closed by the page that opened them. This draws a full-screen iframe instead. No blockers, no navigation, and your page can close it.There is no data-attribute version any more
<button data-xendly-pay data-token=…> form. It took the name of a global callback as a string, so a typo failed silently at the moment a customer had just paid: and it needed the token rendered into the page at load, which means minting a session for every visitor who might click. If you are fetching the token when they click, you are writing JavaScript already, and it may as well be the call above.The iframe is the security boundary, and it is doing real work. The checkout is served from xendlypay.com, so the number your customer types goes into our origin and your page cannot read it or change the amount they are agreeing to. That is the same-origin policy, not a promise from us.
In React and Next.js
Two things a component tree needs that a plain page does not: the script has to be loaded once rather than on every render, and window.XendlyPay has to exist in TypeScript’s world. Both are four lines.
// app/layout.tsx
import Script from "next/script";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
{/* afterInteractive: nothing on the first paint needs it, and the
customer cannot click Pay before the page is interactive anyway. */}
<Script src="https://xendlypay.com/v1/widget.js" strategy="afterInteractive" />
</body>
</html>
);
}
// app/api/checkout/route.ts
//
// THE SERVER HALF. This is where the amount is decided and where your key
// lives. Never NEXT_PUBLIC_ anything: a key with that prefix is compiled into
// the browser bundle and is public the moment you deploy.
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { orderId } = await req.json();
const order = await db.order.findUniqueOrThrow({ where: { id: orderId } });
const res = await fetch("https://api.xendlypay.com/v1/checkouts", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.XENDLY_KEY!}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
amount: order.totalMinor, // from YOUR database, not from the browser
description: `Order #${order.number}`,
reference: order.id,
}),
});
if (!res.ok) {
return NextResponse.json({ error: "Could not start payment" }, { status: 502 });
}
const { payload } = await res.json();
// The token, and nothing else. The browser has no use for the rest.
return NextResponse.json({ token: payload.token });
}
// app/checkout/PayButton.tsx
"use client";
import { useState } from "react";
import { useXendlyPay } from "@/lib/use-xendly-pay";
export function PayButton({ orderId }: { orderId: string }) {
const pay = useXendlyPay();
const [state, setState] = useState<"idle" | "opening" | "waiting" | "paid">("idle");
async function start() {
setState("opening");
const res = await fetch("/api/checkout", {
method: "POST",
body: JSON.stringify({ orderId }),
});
const { token } = await res.json();
pay(token, {
onProcessing: () => setState("waiting"),
// Move the spinner. Do NOT ship the order here — see below.
onPaid: () => setState("paid"),
onFailed: () => setState("idle"),
onCancelled: () => setState("idle"),
});
}
if (state === "paid") return <p>Thanks. Your order is confirmed.</p>;
return (
<button onClick={start} disabled={state !== "idle"}>
{state === "waiting" ? "Check your phone…" : "Pay with Mobile Money"}
</button>
);
}Never NEXT_PUBLIC_XENDLY_KEY
NEXT_PUBLIC_ is inlined into the JavaScript bundle at build time and shipped to every visitor. An API key there is published, not configured. The whole reason the widget takes a session token is so the browser never needs your key: your server route holds it, and the token it hands back is good for one payment of one amount that the browser cannot change.3. Trust your webhook, not the browser
Fulfil on the webhook
onPaid is a convenience for your interface: move a spinner, show a receipt. It is a message from a browser, and a browser is a thing your customer controls. Ship the order, unlock the download, credit the account when the payment.succeeded webhook arrives, because that one comes from us and is signed.The payment carries your reference, so the webhook is enough on its own to find the order it belongs to. You do not need to store the session token.
What the payer sees
Four things, and only four. The demo at the top of this page is the same screen.
| Your business name and the amount | Read from the session, not from the page that opened it. |
| One field | Their mobile money number, with MTN and Airtel shown so they know their line works. The prefix picks the network as they type. |
| A prompt on their phone | They approve with their PIN. The overlay waits and updates itself. |
| A receipt, or a reason | Paid, or the actual refusal: a declined prompt reads differently from an empty wallet, and they can act on the difference. |
Every one of those is rendered by us, from the session your server created. Your page supplies a token and gets told what happened; it never sees the number and never sets the amount.
Testing it
An app in test mode creates test sessions, and the checkout says so at the top. It only prompts numbers on your test list, and those payments are real , read this first.