Skip to content

Payment fields

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

Payment fields invert the hosted checkout. You keep the page, the order summary and the pay button. Wajub renders the inputs that must not touch your DOM, each in its own iframe, and you submit them when you are ready.

That trade buys you layout control and costs you the parts of the hosted page that need a full page: your own custom checkout fields. If you need those, 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
cardThe card provider's own card fields (Stripe), PayPal's card button, or a button that opens the provider's payment window (Paystack, Flutterwave, FedaPay, CinetPay, Kkiapay)You, 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

Each component starts at min-height: 120px, except with variant: 'fields' from Wajub.js 1.7.2, and the SDK sets the iframe height from the checkout every time the content changes. There is nothing to measure. By default the page moves once, when the iframe takes its height. From Wajub.js 1.8.0, loader: 'skeleton' holds that height 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, the processing screen) cover the component, and the iframe grows to show them whole, then returns to the form's height. Until the checkout shipped with Wajub.js 1.7.0, it kept the form's height and cut them off, whatever the Wajub.js version on your page.

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
componentOriginstringAllYour canonical origin, for multi domain shops
frameTitlestringAllThe iframe'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: never

The five rows above componentOrigin are read by the runtime served from js.wajub.com, whatever the version of the package you installed. @wajub/js 1.5.0 brings their types. Through a framework, React passes every option. @wajub/vue and @wajub/svelte pass fields, onBeforeSubmit from 1.5.0, and variant from 1.5.1.

frameTitle is read by the same runtime from Wajub.js 1.7.0, and @wajub/js 1.7.0 brings its type. @wajub/vue and @wajub/svelte pass it from 1.7.0.

loader, readyTimeout, appearance.inherit and the onStateChange, onResize and onRedirect callbacks are read by the runtime from Wajub.js 1.8.0. @wajub/js 1.8.0 brings their types, and @wajub/react, @wajub/vue and @wajub/svelte pass them from 1.8.0. @wajub/angular passes every option of this table from its first version, 1.8.1, onBeforeSubmit as its beforeSubmit input.

componentOrigin defaults to the origin of the page doing the mounting, so a single domain needs nothing here.

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', the iframe arrives as it comes and the component draws its own placeholder inside it: your page moves when the iframe takes its height. From Wajub.js 1.8.0, loader: 'skeleton' makes your page hold the room 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 with min-height, never less than the min-height it has of its own, inline or from your stylesheet. A placeholder of the form, its label, field and hint lines, is drawn in the style of the fields: inputBorderColor, inputBackgroundColor, borderRadius, labels in colorTextSecondary or a dimmed colorText, a neutral grey without them. It pulses gently
While the component loadsThe iframe is invisible (opacity: 0) and out of reach of the keyboard, the pointer and screen readers (inert). The container is aria-busy="true"
At readyThe iframe fades in as the placeholder fades out, in 0.2 seconds. The container then follows the iframe's height, smoothly, and never drops to 0
No ready after 15 secondsWithout readyTimeout, the placeholder gives way and the iframe shows as it is, with no error reported. With readyTimeout, see When the form does not come up
A component loaded again on the pageA new mount(), or another component of the same type and variant, reserves the height its kind had at rest: its first display, before the payer acted. Only a real form counts: never the height of an OTP step, the processing screen or an error, nor of a notice shown instead of the form, such as a session that already ended or a method the session does not offer
focus() while loadingThe focus moves into the form once it is up, unless the payer typed, clicked or moved the focus meanwhile
unmount(), destroy()Everything the loader added goes: placeholder, styles, listeners and timers. The container gets its own styles back, so a framework can mount a component in it again. Two components loading in the same container, since mount() appends, each hold their own room, and the container gets its styles back once neither is loading

The room reserved at first load, in pixels. wajub.componentReserveHeight(type, variant, fields) gives it, and so does componentReserveHeight exported by @wajub/js, synchronously and without waiting for Wajub.js. Once a component of the kind has loaded, its real height at rest replaces the estimate for the page, and componentReserveHeight returns that height. A server render and its hydration must draw the same thing, so hold componentEstimatedHeight(type, variant, fields) there, also exported by @wajub/js from 1.8.0: it always returns the estimate below, on the server and in the browser, never a height learnt on the page.

Typevariant: 'fields'Default variant
card252264
mobileMoney, with fields.country and fields.operator: 'detect'108248
mobileMoney, without fields.country (a country selector)84 more88 more
mobileMoney, without operator: 'detect' (a list of operators)88 more248 more
wallet52152
bankTransfer, crypto76264
payment76450
address428456

The styles that hold the layout are set inline: on the container (position: relative when it was static, min-height, transition), on the iframe, and on the placeholder (.wajub-loader and its children, aria-hidden). Only the pulse's keyframes go in a <style id="wajub-components-loader">, with the nonce of your page's inline scripts or styles. When your Content Security Policy refuses it, the placeholder simply stays still, and the policy is asked once a page, not on every mount(). It is still too for a payer who asks for reduced motion, or with appearance.disableAnimations.

@wajub/js keeps loader: 'none' as its default. The React, Vue and Svelte field components use skeleton unless you say otherwise, from 1.8.0.

When the form does not come up

readyTimeout, in milliseconds, bounds the wait for ready. Past it, onLoadError and the loaderror event receive a WajubError with the code ready_timeout, marked retryable, and a skeleton gives way to the iframe as it is, rather than pulsing on. The timer starts at each mount(), and ready, a load error, unmount() or destroy() stop it. 0 or no value: never. A confirmPayment() waiting on the component rejects with the same error. Without readyTimeout, a skeleton that has heard nothing after 15 seconds shows the iframe as it is and reports nothing: set readyTimeout to be told.

A blocked iframe, an origin your account does not allow, or a network that drops the frame 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 its notice instead of the form, at the notice's own heightStart a new payment and mount the fields on its token
component_origin_missingThe component cannot tell which page it is in: a component URL built by hand, without a valid component_origin, or a page that sends no referrerMount it with Wajub.js or a framework package, which set it
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 iframe itself 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. The notice, the three session_terminal_ codes and component_origin_missing come with the checkout shipped with Wajub.js 1.8.0, whatever the version on your page. Before, such a component sent nothing: a skeleton placeholder stayed, and the notice of a session that had ended was cut to the placeholder's height.

Events

Subscribe with .on(event, handler), which returns the component so calls chain. The same handlers exist as onReady, onChange and the rest in ComponentConfig.

EventPayloadFires when
readyComponentInstanceThe form is on screen and interactive, or at once when the component has no form to show. Until the checkout shipped with Wajub.js 1.7.1, it fired as soon as the session loaded, before the form
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, its origin is missing, its iframe failed, or it did not send ready within readyTimeout (ready_timeout, from Wajub.js 1.8.0). 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, from Wajub.js 1.7.0

Each callback is called just before its event. From Wajub.js 1.8.0, three events have one too: onStateChange for statechange, onResize for resize and onRedirect for redirect. A component's onResize receives { height }, where the hosted checkout's receives the height itself. Passing one changes nothing but its own call, and the iframe is resized either way.

From @wajub/js 1.8.0, 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 before rejecting with confirmation_timeout. 0 waits forever. From Wajub.js 1.8.0, the time does not run while the payment waits on the payer

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 its provider rejected, or a card paid in the provider's own window that failed, was closed, could not open, or whose onBeforeSubmit refused
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 or destroyed while you were waiting on it. From Wajub.js 1.8.0, also when its iframe left the page without unmount() during a step that waits on the payer, checked every second

From Wajub.js 1.8.0, timeout counts only the time the component could answer. It does not run during a step that 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 leaves for the provider's (redirect). It starts over, whole, at the next step, such as PROCESSING or VERIFYING. Before 1.8.0, a payer who took more than a minute to approve on their phone got confirmation_timeout while the payment went through. An iframe that goes silent during one of these steps leaves the promise waiting until the outcome, the session's expiry or destroy(). An iframe taken out of the page during one of them, by a framework that removed its container for example, rejects it with component_unmounted, as unmount() does. A component mounted again starts from INITIATED, not from the step its previous iframe was on.

A decline carries the provider's own 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, whatever form it shows: a Mobile Money number empty or not valid, no operator chosen, a card left incomplete (in Stripe's, Mollie's or Adyen's fields, in the sandbox or Paystack card fields, or in the details a provider's page or window asks for), card fields still loading or unable to load, a bank transfer with no IBAN or account number, a crypto payment with no asset chosen, a form still loading, a card number its provider rejected, the payer's email missing, a security check still running. 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. Until the checkout shipped with Wajub.js 1.7.0, whatever the Wajub.js version on your page, a form that was not ready said nothing, unless the email was missing.

While the payment runs, another submission does nothing, whatever the method, card, Mobile Money, bank transfer or crypto: 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. Until the checkout shipped with Wajub.js 1.7.0, it could tokenize a Stripe or Mollie card a second time, or call onBeforeSubmit again.

A card paid in its provider's own window (Paystack, Flutterwave, FedaPay, CinetPay, Kkiapay) opens that window rather than leaving 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 keeps a Continue on … button: the payer's press there 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: PayPal, Mollie, Djamo or a crypto payment, for example. A component cannot show that page: most providers refuse to be framed, and their return is meant for a whole tab. From Wajub.js 1.7.0, your page goes there instead. The component emits redirect with { url, method }, then Wajub.js takes the page to url, with a form post when method is POST. With an earlier Wajub.js, the iframe goes there itself.

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);
});

From Wajub.js 1.8.0, 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. A provider that takes over the whole tab (Mollie, PayPal, Djamo, Adyen, Paddle, a Stripe redirect method) brings the payer back to the session's Wajub page, in the component's language. That page waits for the outcome and shows it, then sends the payer to the payment's callback with status, reference and trxref, as after a redirect checkout: by itself after a success, when the payer chooses to go back after a failure. Without a callback, the outcome stays on screen. Other providers, such as a crypto provider, Flutterwave, Wave, CinetPay or PayDunya, send the payer back to the callback themselves, without Wajub's parameters.

A provider's page that the payer opens in a new tab from a pending payment, with Continue payment, brings them back the same way, in that tab: through the session's Wajub page, or straight to your callback from the other providers. Your page, still open in the first tab, receives the outcome as usual. With an earlier Wajub.js on your page, that tab comes back to the component alone.

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 serverThrough Wajub's page, the payer comes back with status, reference and trxref. Sent back by the provider itself, such as a crypto provider, Flutterwave, Wave, CinetPay or PayDunya, the payer comes back without Wajub's parameters, 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 has 30 secondsPast that, the payment stops 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.

From Wajub.js 1.7.2, that iframe takes the height of its inputs, with no 120px floor: a phone number field is about 100px, and the floor left a blank band under it.

From 1.8.0, the React, Vue and Svelte field components use loader: 'skeleton' by default, and the Angular ones from their first version, 1.8.1. They hold the room Wajub.js will reserve from the first render, componentEstimatedHeight() on the server and at hydration, then let Wajub.js hold it once the component is mounted, so your box ends at the height of 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. With loader: 'none', they hold the type's own height (card 200, mobileMoney 240, wallet 120, payment 360, address 280, bankTransfer 200, crypto 240): pass 0 there, minHeight={0}, :min-height="0" or [minHeight]="0", so your box fits the inputs. Before 1.8.0, they always held a minimum height of their own.

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

A channel that carries several networks, such as cm.mobile for MTN and Orange in Cameroon, lists each of its operators as a choice of its own, with its logo. The payment goes through that channel, with the chosen operator as a hint. With detect, the list shows only when the number does not tell the operator. Until the checkout shipped with Wajub.js 1.7.1, whatever the version on your page, such a channel was a single choice, made for the payer. A wallet held on any network takes a number of any operator: Wave, Express Union, Djamo and Capitec Pay, and OPay and PalmPay from the checkout shipped with Wajub.js 1.7.2, whatever the version on your page. In Vue and Svelte, variant needs @wajub/vue or @wajub/svelte 1.5.1.

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, with Wajub.js 1.8.0.

  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, since it no longer runs 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. frameTitle renames the iframe
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() here and on the hosted embed

A component sends locale, appearance and layout to its iframe and repaints, and frameTitle renames the iframe itself. From Wajub.js 1.8.0, with the checkout shipped alongside it, the hosted checkout also lays its methods out again on update({ layout }), where it only stored the value. It still has no frameTitle.

update({ appearance }) changes the keys you give and keeps the others. From Wajub.js 1.8.0, 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

Each component exists as a ready made wrapper, with the same options as props, variant included from 1.5.1 in Vue and Svelte. From 1.8.0, bankTransfer and crypto have theirs too, BankTransferComponent and CryptoComponent. Before, use ComponentEmbed with type="bankTransfer" or type="crypto", which still works. In Angular, each one is a standalone component, <wajub-card> to <wajub-crypto>, and <wajub-component> takes its type.

From 1.8.0 in React, Vue and Svelte alike, and in Angular from its first version, 1.8.1:

  • 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 in the wrappers, none in @wajub/js.
  • The container holds the room Wajub.js will reserve from the first render: the estimate, componentEstimatedHeight(type, variant, fields), on the server and at hydration, which must draw the same thing, then componentReserveHeight(), which knows the height a form of that kind took at rest on the page. Once the form is mounted, Wajub.js holds the room, and the container ends at the form's own height. A later render never writes over it. With loader="none", the container holds the type's height instead: 200 for card, 240 for mobileMoney, 120 for wallet, 360 for payment, 280 for address, 200 for bankTransfer, 240 for crypto. A minHeight you pass, or in React, Vue and Angular a min-height in style, is kept for the component's whole life. Up to 1.7.2 there was no loader, and minHeight defaulted to the type's height.
  • appearance, locale, layout and frameTitle reach the mounted component through update(), only when their content changes: an object written inline, new on each render, does nothing. frameTitle is no longer read once at mount.
  • appearance is sent whole: a top-level key removed since is sent as undefined, which clears it, and an appearance that becomes undefined or {} clears all of it, 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.
  • Handlers are read when the event comes: passing a new one never reloads 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 at the next check, as a new object does, and the mount, the update() or the new mount happens right after that check, once the view is written.

Before 1.8.0, only a new sessionId or type remounted the field, the wrapper sent appearance, locale and layout together through update() whenever one of them was a new value, an inline object on every render, frameTitle was read once at mount, and the redirect event had no prop: subscribe on the instance that onInstance, @instance or on:instance hands you.

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 after 60 seconds. From Wajub.js 1.8.0 that time does not run while the payment waits on the payer, for an OTP, an approval on the phone or 3-D Secure.

<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
CouponsNo, switched off todayNo
Custom checkout fieldsYesNo
Full shipping flowNo, switched off todayAddress block only
OTP, PIN, USSD and the other follow-up stepsYesYes

What did you think of this content?