Angular
provideWajub, the service, standalone components, and the Angular SSR wiring that goes with them.
@wajub/angular wraps @wajub/js in Angular syntax: provideWajub() and a service that loads the
runtime once, standalone components for the checkout and each of the seven field groups, and
injectConfirmPayment(). The payment API itself does not change, so the behaviour of every option
is documented on Display the checkout and
Payment fields. This page covers what is specific to
Angular.
@wajub/angular
Stable · GAnpm
- Version
- 1.8.1
- Runtime
- Angular 17+ · Angular SSR
Covers
- Payments
- Payment Links
Not in this package: Billing, Transfers, Sync, Shield, Tax.
Install
@wajub/js is a peer dependency, not a bundled one, so it goes on the same line. The package
needs @wajub/js 1.8.0 or later and Angular 17 or later, @angular/core and @angular/common. It
is published from 1.8.1, ESM only, and declares sideEffects: false.
npm install @wajub/angular @wajub/jsIt is built in Angular's partial compilation mode with Angular 17, so the Angular of your project, 17 or any later version, links it at build time. It works with zone.js and in a zoneless application, and renders on the server, hydration included.
provideWajub and the service
WajubService loads the runtime once, in the browser only, from the moment it is first injected,
which a Wajub component does. provideWajub() configures it: in app.config.ts for the whole
application, or in the providers of a route, which then gets a service of its own. Without it,
the components use the application's service, which loads the runtime from js.wajub.com.
import { type ApplicationConfig } from '@angular/core';
import { provideWajub } from '@wajub/angular';
export const appConfig: ApplicationConfig = {
providers: [provideWajub()],
};| Option | Type | Default | What it does |
|---|---|---|---|
loadOptions | { jsOrigin?, jsUrl? } | CDN defaults | Overrides where the runtime is fetched from |
defer | boolean | false | Loads nothing until you call load() on the service |
Unlike the provider of the React, Vue and Svelte packages, defer works here: load() keeps the
runtime in the service, and the components on the page mount with it. Wajub.js loads once per
page: once it is there, load() with other options loads no other copy, and the first origin or URL
stays. A failed load is tried again on the next call.
getRuntime() throws while the runtime is loading
It throws until the script has arrived, again if the load failed, and always on the server. Read
runtime(), a signal that holds null until the runtime has loaded, unless you already know it
is there.
import { Component, Input, inject } from '@angular/core';
import { WajubService } from '@wajub/angular';
@Component({
selector: 'app-upgrade-button',
standalone: true,
template: `
<button type="button" [disabled]="!wajub.runtime()" (click)="upgrade()">Upgrade plan</button>
`,
})
export class UpgradeButtonComponent {
@Input({ required: true }) sessionId!: string;
readonly wajub = inject(WajubService);
upgrade(): void {
this.wajub.runtime()?.wajub.open({
sessionId: this.sessionId,
onSuccess: () => window.location.assign('/account'),
});
}
}The signal is what makes this button work: it is null when the component is created, and fills
when the runtime arrives. On the server it stays null, and nothing is loaded there.
| Member | What it is |
|---|---|
runtime | A signal of { wajub, Wajub, WajubError }, null until the runtime has loaded, and on the server |
runtime$ | The same as an observable: the current runtime at once, then each new one |
loading, error | Signals: the runtime is loading, and why the last load failed, null otherwise |
load(options?) | A promise of the runtime, null on the server. options default to the loadOptions of provideWajub(), and count only for the first load of the page |
getRuntime() | The runtime. Throws while loading, after a failed load and on the server |
WAJUB_OPTIONS is the injection token provideWajub() sets, if you would rather read its options
yourself.
Checkout embed
<wajub-checkout-embed>, the WajubCheckoutEmbedComponent class, is the hosted checkout inside
your own layout. As in Vue, its inputs are a closed list: sessionId, minHeight, locale,
embedOrigin, theme, appearance, layout, loadingText and showLoading. Its outputs are
the eleven callbacks of EmbeddedConfig,
named without on.
import { Component, Input } from '@angular/core';
import { WajubCheckoutEmbedComponent, type WajubError } from '@wajub/angular';
@Component({
selector: 'app-checkout',
standalone: true,
imports: [WajubCheckoutEmbedComponent],
template: `
<wajub-checkout-embed
[sessionId]="sessionId"
layout="tabs"
[minHeight]="480"
(success)="paid()"
(error)="failed($event)"
></wajub-checkout-embed>
`,
})
export class CheckoutComponent {
@Input({ required: true }) sessionId!: string;
paid(): void {
window.location.assign('/order/complete');
}
failed(error: WajubError | Record<string, unknown>): void {
console.error(error.code, error.message);
}
}| Input or output | Type | Default | What it does |
|---|---|---|---|
sessionId | string | Required | The authorization_token from POST /payments |
minHeight | number | 480 | The host's minimum height in pixels, until the checkout sets its own |
(ready) | CheckoutInstance | none | The checkout is up, with the instance to drive afterwards |
(resize) | number | none | The checkout's new height in pixels, the number onResize receives from mount() |
The other outputs are (success), (error), (loadError), (cancel), (expired),
(stateChange), (methodChange), (breakdown) and (close). The host element is the container,
display: block and full width: put your class and style on it. The component also exposes
the mounted instance as checkoutInstance, null before it mounts, for a viewChild or a
template reference.
What the four wrappers share, every input reaching the checkout through update() and a new
session loading a new checkout, is on
Display the checkout, with this
example in each framework.
Payment fields
Seven components render one field group each: <wajub-card>, <wajub-mobile-money>,
<wajub-wallet>, <wajub-payment>, <wajub-address>, <wajub-bank-transfer> and
<wajub-crypto>, the classes WajubCardComponent to WajubCryptoComponent.
<wajub-component>, the WajubComponentEmbedComponent class, is the same with a required type
input, when the choice is made at runtime. Every option of
ComponentConfig is an input of the same name, and every
callback an output named without on.
import { Component, Input, signal } from '@angular/core';
import {
WajubAddressComponent,
WajubPaymentComponent,
injectConfirmPayment,
type ComponentInstance,
} from '@wajub/angular';
@Component({
selector: 'app-pay-form',
standalone: true,
imports: [WajubAddressComponent, WajubPaymentComponent],
template: `
<wajub-address [sessionId]="sessionId" addressMode="shipping"></wajub-address>
<wajub-payment
[sessionId]="sessionId"
collectAddress="shipping"
[collectName]="true"
(instance)="field.set($event)"
></wajub-payment>
<button type="button" [disabled]="payment.isConfirming() || !field()" (click)="pay()">
{{ payment.isConfirming() ? 'Processing…' : 'Pay now' }}
</button>
`,
})
export class PayFormComponent {
@Input({ required: true }) sessionId!: string;
// Read at each confirm(): a session that changes is followed.
readonly payment = injectConfirmPayment(() => this.sessionId);
// A signal: (instance) emits null during change detection when the form goes.
readonly field = signal<ComponentInstance | null>(null);
pay(): void {
const field = this.field();
if (field) this.payment.confirm(field);
}
}| Input or output | Type | Default | What it does |
|---|---|---|---|
type | ComponentType | Required | Only on <wajub-component> |
loader | 'skeleton' or 'none' | 'skeleton' | 'skeleton': the host holds the form's height from the first render, with a placeholder in your inputs' style, and the form fades in once ready. 'none': the frame as it comes, the default of @wajub/js itself |
minHeight | number | Unset | The host's minimum height in pixels, kept for the component's whole life. A min-height in the host's style, attribute or binding, counts the same. Unset, see below |
readyTimeout | number | Unset | Milliseconds to wait for the form. Past it, (loadError) receives a WajubError with the code ready_timeout. Unset, never |
beforeSubmit | () => boolean | Promise<boolean> | none | onBeforeSubmit, as an input, since an output cannot answer. Runs before the charge, to place your order. It counts when it is set as the form loads, and is called without this |
(instance) | ComponentInstance | null | none | The instance once mounted, null once gone: a new mount, or the component destroyed |
(ready) | ComponentInstance | none | The form is up and interactive |
(stateChange) | { state, method } | none | The payment moved to another step, APPROVAL_REQUIRED while the payer approves on the phone for example |
(resize) | { height } | none | The frame took a new height, in pixels |
(redirect) | { url, method } | none | The payment continues on the provider's own page, where Wajub.js takes your page right after |
The other outputs are (change), (focus), (blur), (success), (error), (loadError) and
(methodChange). The host element is the container Wajub.js mounts the frame in, display: block
and full width: put your class and style on it. It holds the frame alone, so the DOM events
named like the outputs, change, focus, blur and error, never reach it, and (change) is
the output alone. The component exposes the mounted instance as componentInstance, null before
it mounts.
(instance) emits null while the form is destroyed, during change detection. Keep the instance in
a signal, or in a field only your code reads: a plain field that a binding higher in the template
reads raises NG0100, ExpressionChangedAfterItHasBeenChecked, in development.
beforeSubmit is called without this
Like any callback, beforeSubmit is called on its own, so a plain method of your class passed as
[beforeSubmit]="placeOrder" cannot reach your component through this. Declare it as an arrow
function in a field, readonly placeOrder = async () => …, or bind the method in your class. If
it throws or rejects, nothing is charged, and the error goes to Angular's ErrorHandler with a
hint saying so. A new function given after the form has loaded is the one called, without a new
mount.
Leave minHeight unset and the room follows loader, the same in every wrapper: what the host
holds on the server, at hydration and on later checks is on
Payment fields. It ends at the form's
own height, so a variant="fields" box fits its inputs without [minHeight]="0".
An inline [appearance]="{ inherit: true }", a new object on each check, sends nothing, and an
appearance or fields object you change in place counts at the next check, as a new object
does. Several changes in one check go in one update(). A new mount emits null on (instance),
then the new instance.
What the field components share, every input reaching the form through update() or a new mount,
the redirect event and what injectConfirmPayment() guards against, is on
Payment fields.
injectConfirmPayment
injectConfirmPayment(sessionId?) is the useConfirmPayment of the other packages. Call it in an
injection context, a field initializer or a constructor. It returns
{ confirm(instance, options?), isConfirming }.
| Member | What it does |
|---|---|
confirm(instance, options?) | Submits the field component it is given and resolves with the outcome. A second call while a payment is under way returns the same promise. It rejects before the runtime has loaded, and without a session |
isConfirming | A signal: a payment is under way |
The session is read at each confirm(): pass a signal or a function, () => this.sessionId, to
follow one that changes, or a plain string, the SessionIdSource type. A sessionId in options
wins over it, and one left undefined does not. options.components never replaces the instance
you pass.
Change detection
Wajub.js runs outside Angular's zone: the frame's messages, its resizing and its fade-in never
start a check of your application. The outputs are emitted back in the zone, so a zone.js
application refreshes after an event it listens to, and an output nobody listens to costs nothing.
Without zone.js, under provideZonelessChangeDetection(), the template's listener schedules the
check, as for any output. injectConfirmPayment() follows the payment outside the zone as well,
and settles its promise and isConfirming back in it.
The components are OnPush. They act on a new input, and on an object changed in place, at the
next check of their parent: the mount, the update() or the new mount happens right after that
check, once the view is written.
Types
Every type of @wajub/js is exported too, so one import serves both packages:
import type { PaymentSuccess } from '@wajub/angular' works. The inputs and outputs are typed on
the component classes, which Angular's template type checking reads.
The package adds WajubOptions, what provideWajub() takes, SessionIdSource, the session
injectConfirmPayment() takes, and WajubConfirmPayment, what it returns. WajubComponentBase is
the class the field components share: their inputs, their outputs and componentInstance.
Mobile Money and Card in your own checkout
Your checkout keeps its own form, its method choice and its Pay button, and each method shows only
its inputs, in your page's style. The steps, the server calls and the reason for each option are
on Payment fields.
Here, your server has prepared the payment with deferred: true when the checkout opened, and
handed the page its authorization_token and authorization_url.
import { Component, Input, computed, signal } from '@angular/core';
import {
WajubCardComponent,
WajubMobileMoneyComponent,
injectConfirmPayment,
type ComponentInstance,
type WajubError,
} from '@wajub/angular';
// The payment is under way, or waits on the payer: nothing on the page may change.
const RUNNING = [
'PROCESSING', 'VERIFYING', 'PENDING', 'OTP_REQUIRED', 'PIN_REQUIRED', 'PHONE_REQUIRED',
'BIRTHDAY_REQUIRED', 'ADDRESS_REQUIRED', '3DS_REQUIRED', 'USSD_REQUIRED', 'APPROVAL_REQUIRED',
];
@Component({
selector: 'app-payment-step',
standalone: true,
imports: [WajubCardComponent, WajubMobileMoneyComponent],
template: `
<fieldset [disabled]="locked()">
<label><input type="radio" name="method" [checked]="method() === 'mobileMoney'" (change)="method.set('mobileMoney')" /> Mobile Money</label>
<label><input type="radio" name="method" [checked]="method() === 'card'" (change)="method.set('card')" /> Card</label>
</fieldset>
@if (fallback()) {
<p>You will finish on Wajub's secure payment page.</p>
} @else if (method() === 'card') {
<!-- loader="skeleton" is the default: the box keeps its height while the form loads. -->
<wajub-card
[sessionId]="sessionId"
variant="fields"
[appearance]="{ inherit: true }"
[readyTimeout]="10000"
[fields]="{ customer: 'external' }"
[beforeSubmit]="placeOrder"
(instance)="mounted($event)"
(change)="complete.set(!!$event.complete)"
(stateChange)="step.set($event.state)"
(loadError)="loadFailed($event)"
></wajub-card>
} @else {
<wajub-mobile-money
[sessionId]="sessionId"
variant="fields"
[appearance]="{ inherit: true }"
[readyTimeout]="10000"
[fields]="{ customer: 'external', country: 'CM', operator: 'detect' }"
[beforeSubmit]="placeOrder"
(instance)="mounted($event)"
(change)="complete.set(!!$event.complete)"
(stateChange)="step.set($event.state)"
(loadError)="loadFailed($event)"
></wajub-mobile-money>
}
@if (step() === 'APPROVAL_REQUIRED') {
<p role="status">Approve the payment on your phone.</p>
}
<button type="button" [disabled]="locked() || (!fallback() && !complete())" (click)="pay()">Pay</button>
`,
})
export class PaymentStepComponent {
// The prepared payment's authorization_token and authorization_url, from your server.
@Input({ required: true }) sessionId!: string;
@Input({ required: true }) hostedUrl!: string;
readonly method = signal<'mobileMoney' | 'card'>('mobileMoney');
readonly complete = signal(false);
readonly step = signal('INITIATED');
readonly fallback = signal(false);
private readonly payment = injectConfirmPayment(() => this.sessionId);
readonly locked = computed(() => this.payment.isConfirming() || RUNNING.includes(this.step()));
private component: ComponentInstance | null = null;
// Your server checks the cart, places the order and activates the payment.
// An arrow function, so that this is your component when the field calls it.
readonly placeOrder = async (): Promise<boolean> => {
const response = await fetch('/checkout/place-order', { method: 'POST' });
return response.ok; // false: nothing is charged
};
// Switching method destroys one form and mounts the other: null in between.
mounted(instance: ComponentInstance | null): void {
this.component = instance;
this.complete.set(false);
}
loadFailed(error: WajubError | Record<string, unknown>): void {
if (error.code === 'ready_timeout') this.fallback.set(true);
}
async pay(): Promise<void> {
if (this.fallback()) {
// A prepared payment is charged only once activated: place the order first.
if (await this.placeOrder()) window.location.assign(this.hostedUrl);
return;
}
if (!this.component) return;
try {
await this.payment.confirm(this.component);
window.location.assign('/thanks');
} catch {
// The form shows the reason, and the payer can try again in it.
}
}
}The fields show a declined payment themselves, and the payer tries again in them. When they do not
come up within readyTimeout, the Pay button places the order and then sends the payer to Wajub's
page: a prepared payment is charged only once activated. appearance.inherit reads a text input
of your page, its label, your main button and the background behind the fields, so render your own
form's fields before this step, or name them with
[appearance]="{ inherit: { input: '#email', button: '#pay' } }". Mark the order paid on your
server, from the payment.succeeded webhook, never from the browser.
Angular SSR
The session is created on your server. Nothing in this package needs a secret key, and nothing should ever receive one.
import express from 'express';
import { Wajub } from '@wajub/node';
const wajub = new Wajub({ secretKey: process.env['WAJUB_API_KEY']! });
// On the Express app server.ts creates, before the handler that renders Angular.
app.post('/api/checkout/session', express.json(), async (req, res) => {
const { cartId } = req.body;
const payment = await wajub.payments.create(
{
amount: 25000,
currency: 'XAF',
description: `Cart ${cartId}`,
customer: { email: '[email protected]' },
metadata: { cart_id: cartId },
},
{ idempotencyKey: `cart-${cartId}` },
);
res.json({ sessionId: payment.authorization_token });
});src/environments is for the publishable key only
Every environment.ts file is compiled into the browser bundle. pk. belongs there, sk. never
does. A secret key sent from a browser is answered 403 and its owner is emailed about it.
Three more things Angular SSR asks for:
- Nothing to skip. The components render on the server, where nothing reads
windowordocumentand the runtime is not loaded: a field component's host renders empty, already holding the form's estimated height, the same at hydration. The form mounts in the browser after the first render, soprovideClientHydration()needs nothing more: nongSkipHydration, and noisPlatformBrowser()around the components. - Put
provideWajub()inapp.config.ts: the service lives as long as the application, so a navigation inside the checkout flow does not load the runtime again. - Fetch the session from your server and pass the token down as an input.
What the package exports
The exports are listed in the SDK reference, the types the package adds in TypeScript.