afinX Bridge is a hosted widget that handles MTN MoMo USSD approval, bank credential capture, and OTP verification — so you never touch a user's PIN or password. You get a public token via postMessage, exchange it server-side, and you're linked.
https://bridge.afinx.co/link_<token>
Three calls. The widget does everything in between — institution picker, credentials, USSD/OTP — and reports the outcome back via postMessage or a redirect to your app, depending on how you opened it.
Server-side, with your sandbox or live bearer token. Pass redirectUri if you plan to use redirect display mode. Tokens are scoped to the developer and expire in 30 minutes.
curl https://api.afinx.co/bridge/link-token \
-H "Authorization: Bearer afinx_test_sk_…" \
-H "Content-Type: application/json" \
-d '{
"userId": "user_123",
"redirectUri": "https://yourapp.com/bridge/callback"
}'Same widget, two integration shapes. Popup keeps the user on your page and reports back via postMessage. Redirect navigates the browser to the widget and back to your redirectUri. Default is popup.
// Popup mode (default)
window.open(
`https://bridge.afinx.co/${linkToken}`,
'afinx-bridge',
'width=480,height=720',
);
// Redirect mode — better on mobile and popup-blocker environments
window.location.href =
`https://bridge.afinx.co/${linkToken}?mode=redirect`;Take the publicToken from the postMessage event (popup) or the ?public_token= query param on your redirectUri (redirect), and exchange it server-side. Public tokens are single-use and expire in 10 minutes.
curl https://api.afinx.co/bridge/exchange-token \
-H "Authorization: Bearer afinx_test_sk_…" \
-d '{ "publicToken": "public_…" }'Real Ghana institutions, real MFA flows, real edge cases. Built and tested against the providers we ship to production with.
The user dials the USSD prompt, approves on their phone, and we detect the linked account. No PIN ever leaves the handset.
GCB, Ecobank, Access, Fidelity, CalBank. Credentials captured in the widget, never proxied to your frontend.
When an institution requires a second factor, the widget collects it, retries on bad codes, and times out cleanly.
The widget never sees your bearer token. Link tokens are scoped, short-lived, and minted server-side per session.
Pick popup mode for postMessage (BRIDGE_SUCCESS / EXIT / ERROR), or redirect mode for a Stripe-Checkout-style return to your redirectUri. Same widget, your call.
Public tokens self-destruct on first exchange and otherwise expire in 10 minutes. Replay isn't a category of bug here.
Two integration shapes share one widget. Pick popup if your users are on desktop and you want them to stay on your page. Pick redirect if you're on mobile, want OAuth-style trust, or don't want to fight popup blockers.
async function linkAccount(linkToken) {
const popup = window.open(
`https://bridge.afinx.co/${linkToken}`,
'afinx-bridge',
'width=480,height=720',
);
return new Promise((resolve, reject) => {
window.addEventListener('message', (e) => {
if (e.origin !== 'https://bridge.afinx.co') return;
if (e.data?.type === 'BRIDGE_SUCCESS') {
popup?.close();
resolve(e.data.data.publicToken);
}
if (e.data?.type === 'BRIDGE_EXIT') reject(new Error('cancelled'));
if (e.data?.type === 'BRIDGE_ERROR') reject(new Error(e.data.data.message));
});
});
}// 1. open
window.location.href =
`https://bridge.afinx.co/${linkToken}?mode=redirect`;
// 2. handle the callback at your redirectUri
// e.g. /bridge/callback?afinx_status=success
// &public_token=public_…
// &link_token=link_…
const params = new URLSearchParams(location.search);
switch (params.get('afinx_status')) {
case 'success':
exchange(params.get('public_token'));
break;
case 'exit':
showCancelled();
break;
case 'error':
showError(params.get('error_message'));
break;
}