Skip to content

Payment fields

Your layout, your pay button, Wajub renders only the secure inputs.

Payment fields put Wajub's secure inputs inside your own checkout: you keep the page, the order summary and the pay button, and submit the fields when you are ready. If you need your own custom checkout fields inside the payment page, use the full checkout instead.

The steps a payment can take after the first submit stay inside the component: a code sent by SMS (OTP), a wallet PIN, a phone number to confirm, a date of birth, an address, an approval on the phone, a USSD code to dial, and 3D Secure. You wire nothing for them, and confirmPayment() waits until they are done.

The seven components

TypeRendersWho pays
cardCard fields, or a button that opens the provider's secure payment windowYou, with confirmPayment()
mobileMoneyOperator and phone numberYou, with confirmPayment()
bankTransferThe payer's bank accountYou, with confirmPayment()
cryptoThe asset to pay withYou, with confirmPayment()
paymentA method selector and the matching formYou, with confirmPayment()
addressShipping or billing addressNobody, read it with getValue()
walletApple Pay or Google PayThe native button inside the component

The shape of it

A factory holds the session and the shared options. Each create() makes one component, and mount() puts it on the page.

Card fields and your own button
import { components, confirmPayment } from '@wajub/js';

const factory = await components(sessionId, {
  locale: 'fr',
  appearance: { primaryColor: '#2563eb', labels: 'floating' },
});

const card = factory
  .create('card')
  .on('change', (event) => (payButton.disabled = !event.complete))
  .mount('#card');

payButton.onclick = () => confirmPayment({ sessionId, components: card });

Options given to the factory become the defaults for every component it creates. Options given to create() win for that one component.

Components size themselves

There is nothing to measure. By default the page moves once, when the component appears; loader: 'skeleton' holds the room from mount(), see Loading without a layout shift. The screens that follow a submit, an OTP or a PIN, an approval on the phone, a USSD code, 3D Secure, show whole inside the component.

Configuration

OptionTypeApplies toWhat it does
sessionIdstringAllRequired, set once on the factory
localestringAllfr, en, es, pt, ar
appearanceAppearanceConfigAllAppearance
layoutCheckoutLayoutpaymentHow the method selector is arranged
collectAddressshipping or billingpaymentAdds an address block inside the form
addressModeshipping or billingaddressWhich set of labels to use
collectNamebooleanpayment, addressAdds the payer name field
fields.phonealways, auto, neverpayment, addressPhone field visibility
fields.customerauto or externalAll but addressexternal stops asking for the payer's contact, name and address, your server sends them
fields.countryISO 3166 alpha-2mobileMoney, paymentStarts on that country, and hides the country selector when the method covers it
fields.operatorchoose or detectmobileMoney, paymentdetect reads the operator from the number instead of listing the operators, and lists them only when the number does not tell
variantdefault or fieldsAll but addressfields drops the padding and the background, for your own method box
onBeforeSubmit() => boolean | Promise<boolean>All but addressRuns before the charge. false or a throw stops it
frameTitlestringAllThe component's name for screen readers, in your page's language. Defaults to Wajub <type> component, such as Wajub mobileMoney component
loadernone or skeletonAllWhat your page shows while the component loads. none by default. See Loading without a layout shift
readyTimeoutnumber, millisecondsAllHow long to wait for ready before onLoadError receives ready_timeout. 0 or absent: no limit
An address on its own, and a form that carries one
factory
  .create('address', { addressMode: 'shipping', collectName: true, fields: { phone: 'auto' } })
  .mount('#address');

factory
  .create('payment', { collectAddress: 'shipping', collectName: true, layout: 'tabs' })
  .mount('#payment');

Loading without a layout shift

With the default, loader: 'none', your page moves once, when the component takes its height. loader: 'skeleton' holds the room from mount() instead.

The room held from mount()
const momo = factory
  .create('mobileMoney', {
    variant: 'fields',
    fields: { country: 'CM', operator: 'detect' },
    loader: 'skeleton',
    readyTimeout: 10000,
  })
  .mount('#momo-fields');

momo.focus(); // called while loading: the focus moves in once the form is up
WhenWhat your page shows
From mount()The container holds the form's height, and a placeholder of the form, drawn in the style of your fields, pulses gently. The container is aria-busy="true"
At readyThe form fades in as the placeholder fades out
focus() while loadingThe focus moves into the form once it is up, unless the payer typed, clicked or moved the focus meanwhile
unmount(), destroy()The container gets its own styles back, so a component can be mounted in it again

To hold the same room in your own layout before Wajub.js has loaded, in a server render and its hydration for example, use componentEstimatedHeight(type, variant, fields), exported by @wajub/js: it returns the same height on the server and in the browser.

With a strict Content Security Policy, the pulse needs the nonce of your page's inline scripts or styles; without it, the placeholder simply stays still. It is still too for a payer who asks for reduced motion, or with appearance.disableAnimations.

@wajub/js defaults to loader: 'none'. The React, Vue, Svelte and Angular field components default to skeleton.

When the form does not come up

readyTimeout, in milliseconds, bounds the wait for ready, counted from each mount(). Past it, onLoadError and the loaderror event receive a WajubError with the code ready_timeout, marked retryable, and a confirmPayment() waiting on the component rejects with the same error. Without readyTimeout, you are not told: set it.

A blocked frame, an origin your account does not allow, or a network failure are the usual causes. Offer the payer another way to pay rather than a blank box: unmount the fields and send the payer to the hosted page, as Mobile Money and Card in your own checkout does.

onLoadError and loaderror say why a component never started. Nothing was paid.

CodeWhat happenedWhat to do
session_not_foundThe token is unknown, or the session could not be readCheck the token your server handed the page
session_terminal_expired, session_terminal_cancelled, session_terminal_failedThe session already ended without a payment: it expired, was cancelled, or ended on a failed payment. The component shows a notice instead of the formStart a new payment and mount the fields on its token
component_origin_missingThe component was not mounted with Wajub.js or a framework packageMount it with one of them
invalid_componentA component type that does not existUse one of the seven components
method_unavailableThe session offers no method this component can takeRead fetchSession().payment_methods before mounting, and mount a component the session can pay with
load_errorThe component failed to loadCheck the network tab and your Content Security Policy
ready_timeoutNo ready within readyTimeoutOffer another way to pay, as above

A component whose session is already paid shows its notice and sends ready, not an error: the payment is already done, so settle the order from your webhook.

Events

Subscribe with .on(event, handler), which returns the component so calls chain.

EventPayloadFires when
readyComponentInstanceThe form is on screen and interactive, or at once when the component has no form to show
change{ complete, empty, error, value }A field changed or its validity changed
focusNothingA field took focus
blurNothingA field lost focus
successThe transactionThe payment succeeded
errorWajubErrorThe payment failed, or a submission you asked for was refused
loaderrorWajubErrorThe component could not start: its session ended, it failed to load, or it did not send ready within readyTimeout. The codes
resize{ height }The component grew or shrank, or a screen that follows a submit covers it
statechange{ state, method }The checkout moved to another state
methodchange{ methodId }The payer picked another method
redirect{ url, method }Your page is about to leave for the provider's own page

Each event also has a callback in ComponentConfig, called just before it: onReady, onChange and the rest. A component's onResize receives { height }, where the hosted checkout's receives the height itself.

on() and off() type the payload of every known event through ComponentEventMap: component.on('statechange', (event) => event.state) type checks.

Gating your own button
component.on('change', (event) => {
  payButton.disabled = !event.complete;
  errorLabel.textContent = event.error ? event.error.message : '';
});

complete means valid, not paid

change.complete says the fields would pass validation. The money moves on success, and only your webhook proves it moved.

Locking your checkout while the payment runs

Once your server has activated the payment, its amount is fixed. While the payment runs, a payer who changes the method, the delivery or a coupon on your page would leave it with a total that no longer matches. statechange says when the payment is under way: keep your own checkout locked while the state is one of PROCESSING, VERIFYING, PENDING, OTP_REQUIRED, PIN_REQUIRED, PHONE_REQUIRED, BIRTHDAY_REQUIRED, ADDRESS_REQUIRED, 3DS_REQUIRED, USSD_REQUIRED or APPROVAL_REQUIRED. APPROVAL_REQUIRED is the payer approving on their phone.

Your checkout follows the payment
const RUNNING = [
  'PROCESSING', 'VERIFYING', 'PENDING', 'OTP_REQUIRED', 'PIN_REQUIRED', 'PHONE_REQUIRED',
  'BIRTHDAY_REQUIRED', 'ADDRESS_REQUIRED', '3DS_REQUIRED', 'USSD_REQUIRED', 'APPROVAL_REQUIRED',
];

const momo = factory.create('mobileMoney', {
  onStateChange: ({ state }) => {
    const locked = RUNNING.includes(state);
    checkoutOptions.disabled = locked; // a <fieldset>: methods, delivery, coupon
    payButton.disabled = locked;
    hint.textContent = state === 'APPROVAL_REQUIRED' ? 'Approve the payment on your phone.' : '';
  },
});

Lock your own controls, never the component: leave its container visible and reachable, since the steps the payer answers are inside it.

Submitting

confirmPayment takes the session and one mounted component, submits it, and waits for the outcome.

Resolves, or rejects with a WajubError
import type { WajubError } from '@wajub/js';

try {
  const { status, transaction } = await confirmPayment({ sessionId, components: card });
  showReceipt(status, transaction);
} catch (error) {
  // A caught value is `unknown` in strict mode: confirmPayment rejects with a WajubError.
  const { code, message } = error as WajubError;
  showRetry(code, message);
}
OptionDefaultWhat it does
sessionIdRequiredThe session the component was created with
componentsRequiredOne mounted card, mobileMoney, bankTransfer, crypto or payment component
timeout60000Milliseconds to wait for the outcome before rejecting with confirmation_timeout. 0 waits forever. Time the payment spends waiting on the payer does not count

Three rejections happen before anything is submitted, so they are wiring mistakes rather than payment failures.

CodeCause
missing_componentNo component passed, or an object that cannot submit
wallet_not_supportedA wallet component. Its own native button pays
address_not_paymentAn address component. Read it with getValue()

Once submitted, the payment itself ends the wait, and so can the clock.

CodeCause
confirmation_timeoutNo outcome arrived within timeout. The payment goes on and can still succeed
checkout_validationonBeforeSubmit returned false, threw or took more than 30 seconds
submission_failedThe component refused your submission before any charge: a field not ready, a card number refused, or a payment window that was closed or could not open
payment_errorThe API refused the charge. On a prepared payment, it was not activated yet
payment_cancelledThe payer cancelled. Retryable
session_expiredThe session expired before the payment ended
component_unmountedThe component was unmounted, destroyed or removed from the page while you were waiting on it

timeout does not run while the payment waits on the payer, OTP_REQUIRED, PIN_REQUIRED, PHONE_REQUIRED, BIRTHDAY_REQUIRED, ADDRESS_REQUIRED, APPROVAL_REQUIRED, USSD_REQUIRED, 3DS_REQUIRED or PENDING, nor once your page has left for the provider's (redirect). A component mounted again starts from INITIATED.

A decline carries its decline code, and a component that cannot load rejects with its load error, ready_timeout included when you set readyTimeout.

submission_failed answers a submission you asked for, with confirmPayment() or submit(), that the component refused before charging anything: a Mobile Money number empty or not valid, no operator chosen, a card left incomplete, fields still loading, a bank transfer with no IBAN or account number, a crypto payment with no asset chosen, a card number refused, the payer's email missing. Its message says why, in the payer's language. Show it and enable your button again: the payer fixes the field and you submit once more. A payer who presses Enter in a form that is not ready sees the reason in the component, and you receive nothing, since you submitted nothing.

While the payment runs, another submission does nothing: your button, or Enter in a field, neither charges a second time nor calls onBeforeSubmit again, and the payment under way gives you its outcome.

Some cards are paid in the provider's secure window, which opens over your page once onBeforeSubmit accepted the order. When the browser blocks it, error brings submission_failed with a message that says so, and the component shows a button that opens the window for the same payment, without calling onBeforeSubmit again.

When the payment continues on the provider's page

Some payments finish on the provider's own page, a crypto payment for example. Your whole page goes there, and the component emits redirect with { url, method } just before it leaves.

The last thing your page hears
component.on('redirect', () => {
  // The page leaves as soon as this returns: anything asynchronous may not finish.
  sessionStorage.setItem('pending-order', orderId);
});

The onRedirect callback of create() receives the same { url, method }, just before the event. Your page leaves either way, even when the callback or a listener throws.

Your page is gone, and the component with it: success does not fire on it, and confirmPayment() does not settle. Depending on the method, the payer comes back either through the session's Wajub page, which shows the outcome and then sends them to the payment's callback with status, reference and trxref, as after a redirect checkout, or straight to your callback, without those parameters. Without a callback, the outcome stays on Wajub's page.

The callback pageWhy
Exists, and is HTTPS in liveSet it on POST /payments, or on activation for a prepared payment. Without it the payer is not brought back to your site. In the sandbox, http://localhost and http://127.0.0.1 are accepted too
Reads the outcome on your serverThe payer may come back without status, reference and trxref, so make the URL name the order, /orders/4172/return for example. Either way, read GET /payments/{id}, or wait for the payment.succeeded webhook
Shows a payment still pendingA crypto payment can still be confirming on chain when the payer comes back. Say it is on its way, and let the webhook settle the order

Before the order exists

A checkout shows its ways to pay before the order is placed: the buyer is still choosing a delivery, typing a coupon, changing country. Create the session at that point with deferred: true and the payment is only prepared. It stays out of your payments and sends no webhook until your server activates it, once the order is placed. A buyer who leaves leaves nothing behind, as long as you send no customer before activation: a customer sent while preparing is created, with its customer.created.

StepWhereCall
The checkout opensYour serverPOST /payments with deferred: true, the amount and the currency
The total changesYour serverPATCH /payments/{id} with the new amount and currency
The buyer paysYour server, from onBeforeSubmitPlace the order, then POST /payments/{id}/activate
The chargeThe componentRuns once onBeforeSubmit resolved true
Prepare, when the checkout opens
curl https://api.wajub.com/payments \
-H "Authorization: sk_test.xxxxxxxxxxxxxxxx…" \
-H "Content-Type: application/json" \
-d '{ "amount": 35000, "currency": "XOF", "deferred": true, "expires": { "in": 30 } }'

No customer is needed yet. The answer has the shape of a created payment: send authorization_token to the browser as sessionId, and keep transaction.id with the cart. When the total moves, change the same payment rather than preparing another. The mounted components keep their session.

The buyer picked a delivery
curl -X PATCH https://api.wajub.com/payments/trx_test_CSUGajfv9xh0XQ5wu2lx \
-H "Authorization: sk_test.xxxxxxxxxxxxxxxx…" \
-H "Content-Type: application/json" \
-d '{ "amount": 37500, "currency": "XOF" }'

PATCH holds the new total to the rules of POST /payments. An amount outside the currency's limits answers 422 with errors.amount. On a payment prepared with accept, a total that none of the methods it kept can take answers 422 with errors.accept. Either way the payment keeps its previous total.

PATCH also takes expires, { "in": 120 } for example: a new expiry counted from now, from 5 to 43 200 minutes, which the payment keeps once activated. Send it when the checkout stays open longer than you planned. Without it, the expiry does not change. Once the payment has expired, been cancelled or been activated, PATCH answers 409: prepare a new one then.

In the browser, onBeforeSubmit is where the order gets placed. The component calls it after the payer filled the fields and before anything is charged.

Mount now, place the order on submit
const factory = await components(sessionId, { locale: 'fr' });

const momo = factory
  .create('mobileMoney', {
    fields: { customer: 'external' },
    onBeforeSubmit: async () => {
      // Your server checks the cart, places the order, then activates the payment.
      const response = await fetch('/checkout/place-order', { method: 'POST' });
      return response.ok;
    },
  })
  .mount('#momo');

payButton.onclick = () => confirmPayment({ sessionId, components: momo });
Activate, from your order endpoint
curl https://api.wajub.com/payments/trx_test_CSUGajfv9xh0XQ5wu2lx/activate \
-H "Authorization: sk_test.xxxxxxxxxxxxxxxx…" \
-H "Content-Type: application/json" \
-d '{
  "amount": 37500,
  "currency": "XOF",
  "reference": "order-4172",
  "description": "Order #4172",
  "customer": { "email": "buyer@example.com", "name": "Amina Diallo" },
  "metadata": { "order_id": "4172" }
}'

The payment now exists, with the id you kept, and payment.created goes out. The session token in the browser is the same, so the component charges without a remount.

reference and description name the payment after the order, which did not exist when you prepared it: the reference your webhooks match on, and the label the payer reads. They replace the ones given at preparation. Left out, the prepared ones stay.

RuleWhy
Activation carries the prepared totalA different amount or currency answers 422. PATCH first
Activation can be retriedThe same body answers 200 again. Another body, another reference or description included, answers 409
Activation needs the secret keyA publishable key answers 406
onBeforeSubmit must answer within 30 secondsA longer wait stops the payment with checkout_validation
fields.customer: 'external'The payer is not asked for contact details again: activation brings the customer. A card provider that redirects may still ask for an email
DELETE /payments/{id} before activationEnds the session, with no webhook
callback on activationWhere the payer lands after a payment that continued on the provider's page. See When the payment continues on the provider's page

Inside your own payment method box

When your checkout lists the ways to pay itself, one box per method with a radio button, mount the component inside the selected box with variant: 'fields'. It drops its padding and its background, so the inputs sit in your box, which brings the title, the border and the logo. Mobile money also replaces its instructions panel with a one line hint. The other types keep their own panel. The component takes the height of its inputs.

The React, Vue, Svelte and Angular field components default to loader: 'skeleton', so your box ends at the height of the inputs. With loader: 'none', they hold a minimum height of their own: pass 0, minHeight={0}, :min-height="0" or [minHeight]="0", so your box fits the inputs. A minHeight you pass, or in React, Vue and Angular a min-height in style, is kept for the component's whole life.

Mobile money inside its radio box
factory
  .create('mobileMoney', {
    variant: 'fields',
    fields: { customer: 'external', country: 'CM', operator: 'detect' },
    frameTitle: 'Mobile Money payment',
    onBeforeSubmit: placeOrder,
  })
  .mount('#method-mobile-money .fields');
OptionEffect on mobile money
fields.country: 'CM'Starts on that country and hides the selector when the method covers it. Pass the country your form already asked for
fields.operator: 'detect'No operator list. The operator is read from the number and shown in the phone field. The list only appears when the typed number names no operator this payment accepts

Mobile Money and Card in your own checkout

Your checkout keeps its own form, its own method boxes and its own Pay button. Each box shows only the inputs of its method, in your page's style, and the order is placed when the payer pays. This puts the sections above together.

  1. Your server prepares the session when the checkout opens. POST /payments with deferred: true, the amount, the currency and an expiry, with your secret key. Send the page authorization_token, its sessionId, and authorization_url, the hosted page you fall back to. Keep transaction.id with the cart, and PATCH the same payment when the total changes. See Before the order exists.
  2. The page mounts the fields of the method the payer picked, in its box: variant: 'fields', appearance: { inherit: true }, loader: 'skeleton', readyTimeout and fields: { customer: 'external' }, with country and operator: 'detect' for Mobile Money. inherit reads a text input of your form, its label, its submit button and the background behind the box, so render your own fields first. Switching method destroys one component and mounts the other, on the same session.
  3. onBeforeSubmit places the order. Your server checks the cart, creates the order, and activates the payment with POST /payments/{id}/activate: the final total, the customer, your reference, and the callback the payer returns to. Return false to stop: nothing is charged.
  4. Your Pay button submits, with confirmPayment(), or with submit() and the onSuccess and onError callbacks. The default timeout is enough: it does not run while the payer approves.
  5. onStateChange keeps your checkout locked while the payment runs, and tells the payer to approve on their phone during APPROVAL_REQUIRED. See Locking your checkout while the payment runs.
  6. onLoadError with ready_timeout falls back to the hosted page. Remove the fields and turn your Pay button into a way to the hosted page. A prepared payment cannot be charged before it is activated, so that button places the order first, the way onBeforeSubmit does, then sends the browser to authorization_url.
  7. After success, take the payer to your confirmation page, and deliver on the payment.succeeded webhook or GET /payments/{id}, never on the browser alone. A decline is shown in the component, and the payer can try again in the same form.
checkout.ts
import { components, confirmPayment } from '@wajub/js';

// Prepared by your server when the checkout opened (POST /payments, deferred: true).
const { sessionId, hostedUrl } = await fetch('/checkout/payment', { method: 'POST' }).then((r) => r.json());

const RUNNING = [
  'PROCESSING', 'VERIFYING', 'PENDING', 'OTP_REQUIRED', 'PIN_REQUIRED', 'PHONE_REQUIRED',
  'BIRTHDAY_REQUIRED', 'ADDRESS_REQUIRED', '3DS_REQUIRED', 'USSD_REQUIRED', 'APPROVAL_REQUIRED',
];
const factory = await components(sessionId, { locale: 'en' });
const state = { complete: false, running: false, fallback: false };
let fields = null;

function render() {
  checkoutOptions.disabled = state.running; // a <fieldset>: methods, delivery, coupon
  payButton.disabled = state.running || !(state.complete || state.fallback);
}

// Your server checks the cart, creates the order and activates the payment.
async function placeOrder() {
  const response = await fetch('/checkout/place-order', { method: 'POST', body: new FormData(checkoutForm) });
  return response.ok; // false: nothing is charged
}

function mountFields(method) {
  fields?.destroy();
  state.complete = false;
  render();
  fields = factory
    .create(method, {
      variant: 'fields',
      appearance: { inherit: true },
      loader: 'skeleton',
      readyTimeout: 10000,
      fields: method === 'mobileMoney'
        ? { customer: 'external', country: shippingCountry.value, operator: 'detect' }
        : { customer: 'external' },
      frameTitle: method === 'mobileMoney' ? 'Mobile Money payment' : 'Card payment',
      onChange: ({ complete }) => { state.complete = Boolean(complete); render(); },
      onBeforeSubmit: placeOrder,
      onStateChange: ({ state: step }) => {
        state.running = RUNNING.includes(step);
        hint.textContent = step === 'APPROVAL_REQUIRED' ? 'Approve the payment on your phone.' : '';
        render();
      },
      onLoadError: (error) => {
        if (error.code !== 'ready_timeout') return;
        fields.destroy();
        fields = null;
        state.fallback = true;
        payButton.textContent = 'Continue on the secure payment page';
        render();
      },
    })
    .mount(`#method-${method} .fields`);
}

for (const radio of methodRadios) {
  radio.addEventListener('change', () => state.fallback || mountFields(radio.value));
}
mountFields('mobileMoney');

payButton.addEventListener('click', async () => {
  if (state.fallback) {
    // A prepared payment is charged only once activated: place the order first.
    if (await placeOrder()) window.location.assign(hostedUrl);
    return;
  }
  try {
    await confirmPayment({ sessionId, components: fields });
    window.location.assign('/order/complete');
  } catch {
    // The component shows why. The payer corrects it and pays again.
  }
});

onBeforeSubmit can run again when the payer tries again after a decline. Make your order endpoint answer with the same order: activating with the same body answers 200 again. The same recipe in React, Vue, Svelte and Angular uses each package's field components.

Driving a component

MethodWhat it does
mount(container)Attaches to a selector or an element
update({ locale, appearance, layout, frameTitle })Applies the change live, no remount
focus(), blur()Moves focus in and out of the first field. With loader: 'skeleton', focus() called while loading waits for the form
submit()Submits without confirmPayment, you handle the events
selectMethod(id)Switches method on a payment component
isComplete()Whether the fields would pass validation now
getState()The current CheckoutState, INITIATED before anything happens
getError()The last WajubError, or null
getValue()Address values, { name, phone, address, mode }
unmount(), destroy()Remove from the DOM, with or without keeping the instance

update({ appearance }) changes the keys you give and keeps the others. A key given as undefined or null is cleared, and appearance: undefined or null clears the whole appearance: the component goes back to your Dashboard look, and its next mount() starts without it. With appearance.inherit, the page is read again on each call, and removing inherit removes what was read with it. update({ appearance: {} }) changes no key.

Merged, cleared, reset
component.update({ appearance: { borderRadius: '16px' } }); // the other keys stay
component.update({ appearance: { primaryColor: undefined } }); // clears that key
component.update({ appearance: undefined }); // back to your Dashboard look

getValue() returns null until the address component has reported a value, which it does on its first change. Read it in the handler, or read it when the payer submits, not on mount.

Through a framework

In React, Vue and Svelte, each type has its own component, CardComponent to CryptoComponent, and ComponentEmbed takes the type as a prop. In Angular, each one is a standalone component, <wajub-card> to <wajub-crypto>, and <wajub-component> takes its type. In every framework:

  • Every option is a prop: loader, readyTimeout, onStateChange, onResize and onRedirect included. In Angular, options are inputs and callbacks are outputs, (stateChange), (resize), (redirect) and the rest, and onBeforeSubmit is the beforeSubmit input, since an output cannot answer. loader defaults to skeleton.
  • With skeleton, the container holds the form's room from the first render, server rendering included. minHeight is covered in Inside your own payment method box.
  • A change to appearance, locale, layout or frameTitle applies to the mounted component without a reload, so an object written inline is fine.
  • A top-level appearance key removed since the last render is cleared, and an appearance that becomes undefined or {} goes back to your branding.
  • A new sessionId, type, variant, fields, loader, readyTimeout, collectAddress, addressMode or collectName mounts a new component in the same container: the previous one is destroyed first, and the instance callback receives null in between.
  • A new handler takes over without reloading the form.
  • useConfirmPayment, injectConfirmPayment() in Angular, reads the session when you confirm: a confirm kept from an earlier render pays the current session, a sessionId passed to confirm() wins, and the component confirmed is always the one you pass.
  • In Angular, an appearance or fields object changed in place counts, as a new object does.
const { confirm, isConfirming } = useConfirmPayment(sessionId);
const field = useRef<ComponentInstance | null>(null);

<PaymentComponent sessionId={sessionId} onInstance={(i) => (field.current = i)} />;
<button disabled={isConfirming} onClick={() => field.current && confirm(field.current)}>
  Pay now
</button>;

useConfirmPayment guards against a double submission, so a second click while isConfirming is true is ignored. It resolves with { status } and rejects with a WajubError, including confirmation_timeout.

<MobileMoneyComponent
  sessionId={sessionId}
  frameTitle="Mobile Money payment"
  // The page leaves right after: anything asynchronous may not finish.
  onRedirect={rememberPendingOrder}
/>

The provider, the prop tables and the server side wiring are on React, Vue, Svelte and Angular, each with Mobile Money and Card in your own checkout written for that framework.

What you give up

FeatureHosted checkoutPayment fields
Saved payment methodsYesYes
3DSYesYes
WalletsYesYes, through the native button
CouponsNoNo
Custom checkout fieldsYesNo
Full shipping flowNoAddress block only
OTP, PIN, USSD and the other follow-up stepsYesYes

What did you think of this content?