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
| Type | Renders | Who pays |
|---|---|---|
card | The 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() |
mobileMoney | Operator and phone number | You, with confirmPayment() |
bankTransfer | The payer's bank account | You, with confirmPayment() |
crypto | The asset to pay with | You, with confirmPayment() |
payment | A method selector and the matching form | You, with confirmPayment() |
address | Shipping or billing address | Nobody, read it with getValue() |
wallet | Apple Pay or Google Pay | The 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.
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
| Option | Type | Applies to | What it does |
|---|---|---|---|
sessionId | string | All | Required, set once on the factory |
locale | string | All | fr, en, es, pt, ar |
appearance | AppearanceConfig | All | Appearance |
layout | CheckoutLayout | payment | How the method selector is arranged |
collectAddress | shipping or billing | payment | Adds an address block inside the form |
addressMode | shipping or billing | address | Which set of labels to use |
collectName | boolean | payment, address | Adds the payer name field |
fields.phone | always, auto, never | payment, address | Phone field visibility |
fields.customer | auto or external | All but address | external stops asking for the payer's contact, name and address, your server sends them |
fields.country | ISO 3166 alpha-2 | mobileMoney, payment | Starts on that country, and hides the country selector when the method covers it |
fields.operator | choose or detect | mobileMoney, payment | detect reads the operator from the number instead of listing the operators, and lists them only when the number does not tell |
variant | default or fields | All but address | fields drops the padding and the background, for your own method box |
onBeforeSubmit | () => boolean | Promise<boolean> | All but address | Runs before the charge. false or a throw stops it |
componentOrigin | string | All | Your canonical origin, for multi domain shops |
frameTitle | string | All | The iframe's name for screen readers, in your page's language. Defaults to Wajub <type> component, such as Wajub mobileMoney component |
loader | none or skeleton | All | What your page shows while the component loads. none by default. See Loading without a layout shift |
readyTimeout | number, milliseconds | All | How 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.
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.
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| When | What 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 loads | The 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 ready | The 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 seconds | Without 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 page | A 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 loading | The 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.
| Type | variant: 'fields' | Default variant |
|---|---|---|
card | 252 | 264 |
mobileMoney, with fields.country and fields.operator: 'detect' | 108 | 248 |
mobileMoney, without fields.country (a country selector) | 84 more | 88 more |
mobileMoney, without operator: 'detect' (a list of operators) | 88 more | 248 more |
wallet | 52 | 152 |
bankTransfer, crypto | 76 | 264 |
payment | 76 | 450 |
address | 428 | 456 |
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.
| Code | What happened | What to do |
|---|---|---|
session_not_found | The token is unknown, or the session could not be read | Check the token your server handed the page |
session_terminal_expired, session_terminal_cancelled, session_terminal_failed | The 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 height | Start a new payment and mount the fields on its token |
component_origin_missing | The 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 referrer | Mount it with Wajub.js or a framework package, which set it |
invalid_component | A component type that does not exist | Use one of the seven components |
method_unavailable | The session offers no method this component can take | Read fetchSession().payment_methods before mounting, and mount a component the session can pay with |
load_error | The iframe itself failed to load | Check the network tab and your Content Security Policy |
ready_timeout | No ready within readyTimeout | Offer 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.
| Event | Payload | Fires when |
|---|---|---|
ready | ComponentInstance | The 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 |
focus | Nothing | A field took focus |
blur | Nothing | A field lost focus |
success | The transaction | The payment succeeded |
error | WajubError | The payment failed, or a submission you asked for was refused |
loaderror | WajubError | The 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.
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.
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.
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);
}| Option | Default | What it does |
|---|---|---|
sessionId | Required | The session the component was created with |
components | Required | One mounted card, mobileMoney, bankTransfer, crypto or payment component |
timeout | 60000 | Milliseconds 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.
| Code | Cause |
|---|---|
missing_component | No component passed, or an object that cannot submit |
wallet_not_supported | A wallet component. Its own native button pays |
address_not_payment | An address component. Read it with getValue() |
Once submitted, the payment itself ends the wait, and so can the clock.
| Code | Cause |
|---|---|
confirmation_timeout | No outcome arrived within timeout. The payment goes on and can still succeed |
checkout_validation | onBeforeSubmit returned false, threw or took more than 30 seconds |
submission_failed | The 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_error | The API refused the charge. On a prepared payment, it was not activated yet |
payment_cancelled | The payer cancelled. Retryable |
session_expired | The session expired before the payment ended |
component_unmounted | The 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 |
confirmation_timeout cancels nothing
It only ends your wait. The payer may still be approving on their phone, and the payment can
succeed after the rejection. Before offering to pay again, listen for success on the component
or wait for the payment.succeeded webhook.
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.
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 page | Why |
|---|---|
| Exists, and is HTTPS in live | Set 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 server | Through 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 pending | A 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.
| Step | Where | Call |
|---|---|---|
| The checkout opens | Your server | POST /payments with deferred: true, the amount and the currency |
| The total changes | Your server | PATCH /payments/{id} with the new amount and currency |
| The buyer pays | Your server, from onBeforeSubmit | Place the order, then POST /payments/{id}/activate |
| The charge | The component | Runs once onBeforeSubmit resolved true |
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.
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.
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 });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.
| Rule | Why |
|---|---|
| Activation carries the prepared total | A different amount or currency answers 422. PATCH first |
| Activation can be retried | The same body answers 200 again. Another body, another reference or description included, answers 409 |
| Activation needs the secret key | A publishable key answers 406 |
onBeforeSubmit has 30 seconds | Past 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 activation | Ends the session, with no webhook |
callback on activation | Where the payer lands after a payment that continued on the provider's page. See When the payment continues on the provider's page |
onBeforeSubmit is your order flow, not the guard
Until it is activated, a prepared payment refuses every charge with 409, whatever the browser
sends. Activate only from your server, after your own checks, and with the amount your server
computed.
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.
factory
.create('mobileMoney', {
variant: 'fields',
fields: { customer: 'external', country: 'CM', operator: 'detect' },
frameTitle: 'Mobile Money payment',
onBeforeSubmit: placeOrder,
})
.mount('#method-mobile-money .fields');| Option | Effect 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.
- Your server prepares the session when the checkout opens.
POST /paymentswithdeferred: true, the amount, the currency and an expiry, with your secret key. Send the pageauthorization_token, itssessionId, andauthorization_url, the hosted page you fall back to. Keeptransaction.idwith the cart, andPATCHthe same payment when the total changes. See Before the order exists. - The page mounts the fields of the method the payer picked, in its box:
variant: 'fields',appearance: { inherit: true },loader: 'skeleton',readyTimeoutandfields: { customer: 'external' }, withcountryandoperator: 'detect'for Mobile Money.inheritreads 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. onBeforeSubmitplaces the order. Your server checks the cart, creates the order, and activates the payment withPOST /payments/{id}/activate: the final total, the customer, yourreference, and thecallbackthe payer returns to. Returnfalseto stop: nothing is charged.- Your Pay button submits, with
confirmPayment(), or withsubmit()and theonSuccessandonErrorcallbacks. The defaulttimeoutis enough, since it no longer runs while the payer approves. onStateChangekeeps your checkout locked while the payment runs, and tells the payer to approve on their phone duringAPPROVAL_REQUIRED. See Locking your checkout while the payment runs.onLoadErrorwithready_timeoutfalls 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 wayonBeforeSubmitdoes, then sends the browser toauthorization_url.- After
success, take the payer to your confirmation page, and deliver on thepayment.succeededwebhook orGET /payments/{id}, never on the browser alone. A decline is shown in the component, and the payer can try again in the same form.
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
| Method | What 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.
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 lookgetValue() 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,onResizeandonRedirectincluded. In Angular, options are inputs and callbacks are outputs,(stateChange),(resize),(redirect)and the rest, andonBeforeSubmitis thebeforeSubmitinput, since an output cannot answer.loaderdefaults toskeletonin the wrappers,nonein@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, thencomponentReserveHeight(), 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. Withloader="none", the container holds the type's height instead: 200 forcard, 240 formobileMoney, 120 forwallet, 360 forpayment, 280 foraddress, 200 forbankTransfer, 240 forcrypto. AminHeightyou pass, or in React, Vue and Angular amin-heightinstyle, is kept for the component's whole life. Up to 1.7.2 there was no loader, andminHeightdefaulted to the type's height. appearance,locale,layoutandframeTitlereach the mounted component throughupdate(), only when their content changes: an object written inline, new on each render, does nothing.frameTitleis no longer read once at mount.appearanceis sent whole: a top-level key removed since is sent asundefined, which clears it, and anappearancethat becomesundefinedor{}clears all of it, back to your branding.- A new
sessionId,type,variant,fields,loader,readyTimeout,collectAddress,addressModeorcollectNamemounts a new component in the same container: the previous one is destroyed first, and the instance callback receivesnullin 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: aconfirmkept from an earlier render pays the current session, asessionIdpassed toconfirm()wins, and the component confirmed is always the one you pass.- In Angular, an
appearanceorfieldsobject changed in place counts at the next check, as a new object does, and the mount, theupdate()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
| Feature | Hosted checkout | Payment fields |
|---|---|---|
| Saved payment methods | Yes | Yes |
| 3DS | Yes | Yes |
| Wallets | Yes | Yes, through the native button |
| Coupons | No, switched off today | No |
| Custom checkout fields | Yes | No |
| Full shipping flow | No, switched off today | Address block only |
| OTP, PIN, USSD and the other follow-up steps | Yes | Yes |