Skip to content

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.

The cart page
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.

No SDK on the page at all
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.

A modal over your own interface
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.

React
'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.

One component, every method
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.

A summary above the embed
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 situationThe pattern
A shop checkout pageInline mount with onBreakdown
A landing page or an MVPRedirect to authorization_url
A payment inside a running appopen() overlay
A funnel you already designedPayment fields and your own button
WordPress, or plain HTMLThe CDN script and mount
Next.js, Nuxt, SvelteKitThe framework provider and CheckoutEmbed

What did you think of this content?