Skip to content

Embedded checkout

A payment’s hosted checkout can open in a modal frame on your shop’s page instead of replacing it. The frame shows the checkout’s own pages, with the same rules as the redirect: the payer confirms there, and your server learns the outcome from Futa, never from the page. Below 720 px the modal fills the screen; from 720 px it is a sheet 400 px wide and up to 760 px tall, with a « Fermer » button above the frame. Its label is in the first of checkout_url’s lang and your page’s <html lang> that is French, English, Lingala or Swahili, else in French: “Close” in English, “Kokanga” in Lingala, “Funga” in Swahili.

On your server, create the payment with embedding, naming the origin of the page that will open it:

{
"amount": 2500000,
"currency": "CDF",
"merchant_reference": "order-10482",
"description": "Order 10482",
"return_url": "https://shop.example/thanks",
"expires_at": "2026-10-20T12:00:00Z",
"embedding": { "mode": "frame", "origin": "https://shop.example" }
}

The origin is your page’s, as window.location.origin gives it: https://, a host name in lower case, and a port only when it is not 443, with no path, not even a final /, at most 255 characters. An IP address in any spelling (a last label all digits, or 0x followed by hexadecimal digits), localhost and any name under it are refused over https; the sandbox alone, hosted or on your machine, also takes http://127.0.0.1:<port> and http://localhost:<port>. Any other spelling answers 400 INVALID_REQUEST, never rewritten. Compute expires_at once per order, within the next 24 hours, as the quick start says: the instant above is an example. The Idempotency-Key binds embedding with the other terms, direct mode (method and msisdn) refuses it, and payment links never take one. Only that origin may frame the payment’s pages; without embedding they refuse every frame, as before.

Load the opener with a <script> tag in your page’s document, from the checkout’s origin, as a classic script or type="module": in the hosted sandbox, https://checkout.sandbox.futa-app.com, as below; on your machine, http://127.0.0.1:18140. It sets window.FutaCheckout:

<script src="https://checkout.sandbox.futa-app.com/sdk/v1/checkout.js"></script>

The opener takes the checkout’s origin from its own <script> tag and from nothing else, so loading it with import(), which leaves no tag, or from a script inside a shadow root is not supported. It is served at /sdk/v1/checkout.js only, and nothing is published to npm.

// checkoutUrl: the checkout_url your server received for this order's payment, written into the page by your
// server. Never take it from the page's address or from anything a customer typed.
function pay(checkoutUrl, payButton) {
if (!window.FutaCheckout) return window.location.assign(checkoutUrl);
try {
FutaCheckout.open({
checkoutUrl,
onState: ({ payment, state, final }) => showProgress(payment, state, final),
onClose: ({ final, reason }) => readOrderFromServer(),
returnFocus: payButton,
});
} catch {
window.location.assign(checkoutUrl);
}
}
Option What it does
checkoutUrl The payment’s checkout_url exactly as Futa issued it: /pay/<id> on the checkout’s origin with one token and at most a lang. Any other address throws.
onState Called with { payment, state, final } each time the checkout’s page shows a state. Advisory.
onClose Called once with { final, reason }: always for closed, and for the other reasons only with fallback: "none"; with the default, the payer simply leaves for the checkout’s own page.
fallback "redirect", the default: when the frame cannot show the checkout, the payer goes to the checkout’s own page. "none": the payer stays, and onClose says why.
returnFocus The element the focus returns to on close.

open returns { close(), fallBack() }: close() closes the modal as « Fermer » does, and fallBack() leaves it for the fallback. When open throws, because the opener cannot tell the checkout’s origin or checkoutUrl is not the checkout’s address, it has gone nowhere; send the payer to checkout_url yourself, as you do when window.FutaCheckout is missing because the script was blocked or failed to load.

state is the state the server rendered the page in: CREATED, PENDING_APPROVAL, UNCERTAIN, APPROVED, DECLINED, EXPIRED or CANCELLED. final says the page leaves the payer nothing more to do there, not that anything was paid: a decline, an expiry or a cancellation with no replacement offered is final too. payment is the payment the frame shows, which after a decline may be a replacement Futa started on its page rather than the one you opened, so match payments by id on your server.

Events are advisory. Confirm a payment on your server, by its signed webhook or GET /v1/payments/{id}, and fulfil from there. The frame’s messages follow checkout-frame-message-v1.schema.json, with its vectors: they go to your payment’s origin alone, never to "*", carry the payment’s id, its state and final, and never a capability, an address, a number, a participant, a method or an amount. The checkout takes no command from your page.

When it is called, onClose gets final and one of the reasons below. For closed, final is as the checkout last said it, false before it said any; for every other reason, it is false, even after a final state.

reason When
closed The payer pressed « Fermer » or Escape, the browser closed the dialog (Android’s back gesture), or you called close().
timeout The frame said nothing within 15 seconds.
refused The frame’s load ended without a word: the browser refused it, as it does when your page’s origin is not the one the payment names.
unsupported Opera Mini, or a browser without postMessage: no frame can talk to your page.
requested You called fallBack().

With the default fallback, every reason but closed sends the payer to the checkout’s own page, where they pay as with a redirect, and onClose is not called; with fallback: "none", the payer stays, and onClose gets final: false and the reason.

« Retour à … » and the bank’s handoff open at the top level: issuer pages refuse frames, and bank credentials are never typed inside your page. A bank payment started in the modal is therefore finished at the top level, and your return_url brings the payer back. Receipts download from the frame. A framed payment never offers to remember the payer’s method on the device.

  • Content Security Policy. If your page sends one, allow the checkout’s origin in script-src, for the opener, and in frame-src, for its pages: in the hosted sandbox, frame-src https://checkout.sandbox.futa-app.com; on your machine, frame-src http://127.0.0.1:18140.
  • Permissions Policy. The frame is allowed web-share and clipboard-write (its allow attribute), which the checkout’s share and copy buttons use: a Permissions-Policy on your page that denies them silences those buttons.
  • A frame you build yourself. Use the opener. If you frame checkout_url without it and set a sandbox attribute, give it the opener’s own tokens, allow-scripts allow-forms allow-same-origin allow-downloads allow-top-navigation-by-user-activation. Without allow-same-origin each of the checkout’s forms answers 403; without allow-forms the browser sends nothing; without allow-top-navigation-by-user-activation the bank’s handoff and « Retour à … » are blocked, so a bank payment cannot start; without allow-downloads the receipts are; without allow-scripts the frame tells your page nothing. The payer is then stuck in your modal, though nothing claims an outcome.
  • The capability in the frame’s address. The frame’s src is the payment’s checkout_url, which opens that payment’s pages and receipts. Block the checkout’s frame in session-replay tools, with iframe[src^="https://checkout.sandbox.futa-app.com/pay/"] in the hosted sandbox or iframe[src^="http://127.0.0.1:18140/pay/"] on your machine, and keep the checkout’s /pay/ addresses out of what monitoring and analytics agents record, resource-timing names included.
  • Focus. The focus starts on « Fermer »; Escape closes the modal while the focus is outside the frame. On close it returns to returnFocus, else to what had it at open, or to the control the payer pressed inside that. A « Payer » button inside an auto popover (popover) closes with it as the modal opens, since a modal dialog closes every auto popover, and the focus then has nowhere to return: pass returnFocus a control outside it, or make the popover popover="manual".