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
| Type | Renders | Who pays |
|---|---|---|
card | Card fields, or a button that opens the provider's secure payment window | 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
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
| 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 |
frameTitle | string | All | The component'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: no limit |
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.
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, and a placeholder of the form, drawn in the style of your fields, pulses gently. The container is aria-busy="true" |
At ready | The form fades in as the placeholder fades out |
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() | 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.
| 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 a notice instead of the form | Start a new payment and mount the fields on its token |
component_origin_missing | The component was not mounted with Wajub.js or a framework package | Mount it with one of them |
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 component 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.
Events
Subscribe with .on(event, handler), which returns the component so calls chain.
| Event | Payload | Fires when |
|---|---|---|
ready | ComponentInstance | The 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 |
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, 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.
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 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.
| 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 refused, or a payment window that was closed or could not open |
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, destroyed or removed from the page while you were waiting on it |
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.
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.
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 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 | The 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 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 must answer within 30 seconds | A 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 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, 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. 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.
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 |
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.
- 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: it does not run 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 |
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.
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
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,onResizeandonRedirectincluded. In Angular, options are inputs and callbacks are outputs,(stateChange),(resize),(redirect)and the rest, andonBeforeSubmitis thebeforeSubmitinput, since an output cannot answer.loaderdefaults toskeleton. - With
skeleton, the container holds the form's room from the first render, server rendering included.minHeightis covered in Inside your own payment method box. - A change to
appearance,locale,layoutorframeTitleapplies to the mounted component without a reload, so an object written inline is fine. - A top-level
appearancekey removed since the last render is cleared, and anappearancethat becomesundefinedor{}goes 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. - A new handler takes over without reloading 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, 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
| Feature | Hosted checkout | Payment fields |
|---|---|---|
| Saved payment methods | Yes | Yes |
| 3DS | Yes | Yes |
| Wallets | Yes | Yes, through the native button |
| Coupons | No | No |
| Custom checkout fields | Yes | No |
| Full shipping flow | No | Address block only |
| OTP, PIN, USSD and the other follow-up steps | Yes | Yes |