🌉 Account linking widget

Let users connect their
MoMo and bank accounts

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.

Hosted — no PIN handling on your sideSingle-use public tokenspostMessage to your opener
bridge URL patternlink tokens expire in 30 min
https://bridge.afinx.co/link_<token>

How it works

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.

1

Mint a link token

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"
      }'
2

Open the widget — popup or redirect

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`;
3

Exchange the public token

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_…" }'
Available in sandbox

What the widget handles

Real Ghana institutions, real MFA flows, real edge cases. Built and tested against the providers we ship to production with.

MTN MoMo via USSD

The user dials the USSD prompt, approves on their phone, and we detect the linked account. No PIN ever leaves the handset.

Ghanaian banks

GCB, Ecobank, Access, Fidelity, CalBank. Credentials captured in the widget, never proxied to your frontend.

OTP & MFA challenges

When an institution requires a second factor, the widget collects it, retries on bad codes, and times out cleanly.

No client-side secrets

The widget never sees your bearer token. Link tokens are scoped, short-lived, and minted server-side per session.

postMessage or redirect

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.

Single-use exchange

Public tokens self-destruct on first exchange and otherwise expire in 10 minutes. Replay isn't a category of bug here.

Wire it up in an evening

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.

popup modepostMessage
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));
    });
  });
}
redirect mode?afinx_status=…
// 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;
}