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.

Xendly PayTest

Kigali Coffee Co

RWF 5,000

Order #1234

MTNairtel

You will get a prompt on your phone. Approve it with your mobile money PIN.

Demo only. Nothing is charged. On your site the window closes by itself once the payment is over.

How it fits together

Three moving parts, and only the first one is yours to write:

  1. Your server creates a checkout session with your app key. This is where the amount is decided.
  2. Your page passes the token to XendlyPay.open().
  3. 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

The widget runs in your customer’s browser, and your customer is the person with the most to gain from the amount being wrong. Anything your page hands the script is a value they can change with the console open. A session fixes the amount on a request we authenticated with your key, and the browser only ever learns a token that points at it.

1. Create a session

On your server, when the customer is ready to pay. Use your app key.

Request
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"
}
Response
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
amountrequiredMinor units. 5000 = 5,000 RWF. Minimum 100.
descriptionoptionalShown to the payer in the overlay.
referenceoptionalYour own order id. Echoed on the payment and in webhooks; never shown to the payer.
msisdnoptionalPre-fill the number. When set, the payer cannot change it: use it when you already know who is paying.
success_urloptionalOnly used when the checkout is opened as its own page rather than in the overlay. Must be an absolute http(s) URL.
cancel_urloptionalWhere it sends them if they give up.
expires_inoptionalSeconds. Default 30 minutes, clamped to between 2 minutes and 12 hours.
methodoptionalWhich 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

A session can only produce one payment, and only while it is 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.

Your page
<script src="https://xendlypay.com/v1/widget.js"></script>
When your customer clicks pay
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.
phoneOptional. 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.
themeOptional. "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.
ClosingIt 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

The previous version called 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

There used to be a <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

Anything prefixed 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 amountRead from the session, not from the page that opened it.
One fieldTheir 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 phoneThey approve with their PIN. The overlay waits and updates itself.
A receipt, or a reasonPaid, 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.