Use cases
Six real integrations, and the reason each one picks the shape it does.
Every pattern below is the same three moving parts: a session made on your server, a surface in the browser, a webhook that confirms. What changes is where the surface goes and how much of the page stays yours.
An online shop, checkout on the page
The cart stays yours, the payment is a section of it, and the customer never leaves your domain.
onBreakdown is what keeps your total honest when a coupon or a shipping choice moves it.
import { mount } from '@wajub/js';
const { sessionId } = await fetch('/api/checkout/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cartId }),
}).then((r) => r.json());
await mount('#checkout', {
sessionId,
layout: 'tabs',
onBreakdown: (b) => {
cartTotal.textContent = `${b.total.toLocaleString()} ${b.currency}`;
},
onSuccess: () => (window.location.href = '/order/complete'),
onError: (error) => showToast(error.message),
});The full hosted flow comes with it: coupons, OTP, 3DS, shipping. Your colours and logo come from your account branding, so there is nothing to style here unless you want this page to differ.
A landing page, as little code as possible
Your server already has authorization_url from POST /payments. Send the customer there.
window.location.href = authorizationUrl;The customer comes back to your callback URL with a status query parameter. Read it to pick
the page you show, then confirm properly on the
webhook. Details on
Sessions and security.
This is the fastest thing to ship and the easiest to keep working, which makes it the right first integration for most merchants.
An upgrade inside an app
The customer is already doing something. Payment interrupts it, and should give the page back when it is done.
import { open } from '@wajub/js';
upgradeButton.onclick = async () => {
const { sessionId } = await fetch('/api/billing/upgrade', { method: 'POST' })
.then((r) => r.json());
await open({
sessionId,
closeOnOverlay: false,
onSuccess: () => refreshAccount(),
onCancel: () => track('upgrade_cancelled'),
});
};closeOnOverlay: false is the default, and it is the right default here. A misplaced click
should not throw away a payment in progress.
Dismissing the modal only hides it. The session is still open, so the same token remounts if the customer changes their mind.
A subscription form you designed
You own the plan selector and the button. Wajub renders the card fields and nothing else.
'use client';
import { useRef, useState } from 'react';
import type { ComponentInstance } from '@wajub/js';
import { CardComponent, useConfirmPayment } from '@wajub/react';
export function Subscribe({ sessionId }: { sessionId: string }) {
const { confirm, isConfirming } = useConfirmPayment(sessionId);
const card = useRef<ComponentInstance | null>(null);
const [canPay, setCanPay] = useState(false);
return (
<>
<h2>Pro plan, 25 000 XAF per month</h2>
<CardComponent
sessionId={sessionId}
appearance={{ labels: 'floating' }}
onInstance={(instance) => (card.current = instance)}
onChange={(event) => setCanPay(Boolean(event.complete))}
/>
<button
disabled={!canPay || isConfirming}
onClick={() => card.current && confirm(card.current)}
>
{isConfirming ? 'Processing…' : 'Subscribe'}
</button>
</>
);
}useConfirmPayment returns an object, not a function. Destructure it. isConfirming is what
stops a second click from opening a second payment.
No coupons and no OTP in this mode. If the plan page needs a promotional code, the hosted checkout is the shorter path.
Mobile Money, with the methods in tabs
The payment component carries the selector and the matching form, so one component covers card,
Mobile Money and wallets at once.
import { components, confirmPayment } from '@wajub/js';
const factory = await components(sessionId, { layout: 'tabs' });
const payment = factory
.create('payment', { collectName: true, fields: { phone: 'always' } })
.on('change', (event) => (payButton.disabled = !event.complete))
.mount('#payment');
payButton.onclick = () => confirmPayment({ sessionId, components: payment });fields.phone: 'always' is worth setting when Mobile Money is your main channel. On auto the
field only appears when the chosen method needs it, which reads as the form jumping.
Showing the amount before the checkout loads
fetchSession reads the session with the token itself, so it works in the browser before
anything is mounted.
import { fetchSession, preload } from '@wajub/js';
const preview = await fetchSession(sessionId);
amount.textContent = `${preview.amount.toLocaleString()} ${preview.currency}`;
merchant.textContent = preview.merchant_name;
if (preview.environment === 'sandbox') showSandboxBanner();
if (preview.saved_methods?.length) showReturningCustomerHint();
preload(sessionId);preload(sessionId) opens a connection to the checkout origin, so the iframe has nothing to
negotiate when it mounts. Call it when the customer reaches the cart, not when they click pay.
preload(sessionId, { iframe: true }) goes further and loads the whole checkout in a hidden
frame. It makes the mount feel instant and it costs a full page load, so use it only where you
are confident the customer is about to pay.
Picking one
| Your situation | The pattern |
|---|---|
| A shop checkout page | Inline mount with onBreakdown |
| A landing page or an MVP | Redirect to authorization_url |
| A payment inside a running app | open() overlay |
| A funnel you already designed | Payment fields and your own button |
| WordPress, or plain HTML | The CDN script and mount |
| Next.js, Nuxt, SvelteKit | The framework provider and CheckoutEmbed |
Use it through a framework