Checking South African bank account balances programmatically

Knowing a bank account balance programmatically sounds like it should be simple. For most of the world, it is — open banking APIs in Europe and the US have made balance lookups a commodity. In South Africa, it is still a problem worth solving deliberately.

This guide is for developers who need to read balances (and transaction totals that proxy for balances) from South African bank accounts — for credit assessment, cash flow monitoring, automated accounting, or any other use case where "log in and look at the screen" is not a workflow.

What you are actually asking for

When a developer says "check the balance," they usually mean one of three things:

  1. Current available balance — what can be spent right now, including overdraft.
  2. Ledger balance — the formal balance excluding pending transactions.
  3. Running balance derived from transactions — calculated by summing all credits and debits from a known starting point.

South African banks that do expose any data typically surface all three in some form. The challenge is getting to them.

The SA banking landscape for developers

There is no unified, publicly accessible API across SA banks in 2026. The SARB's Payments Ecosystem Modernisation Programme is working toward open banking regulation, but formal PSP licences are still pending.

What exists today:

Nedbank API Marketplace — Nedbank was the first African bank to publish an OAuth 2.0 developer portal. Their Accounts API exposes balance and transaction data for Nedbank customers. Access requires application and approval.

FNB Integration Channel — FNB offers business-to-business integrations for corporate clients, including account data feeds. It is designed for enterprise use, not self-serve developer access.

Standard Bank — Running limited open banking pilots with selected partners. No public API.

ABSA, Capitec — No public developer API for account data as of 2026.

For most developers building on any stack other than Nedbank's specific integration: you need an intermediary layer. Building your own bank connector is not a viable path for a small team.

Reading a balance via structured data

If you connect through BankLink (or a similar SA-market intermediary), balance data comes back as part of the transaction payload. Here is what the response looks like:

{
  "account_number": "62012345678",
  "bank": "fnb",
  "account_type": "cheque",
  "synced_at": "2026-06-08T09:15:00Z",
  "balance": {
    "available": 14820.00,
    "ledger": 15200.00,
    "currency": "ZAR"
  },
  "transactions": [
    {
      "id": "txn_9f3c21a",
      "date": "2026-06-07",
      "description": "WOOLWORTHS FOOD CAPE QTR",
      "amount": -342.50,
      "running_balance": 14820.00,
      "type": "debit",
      "reference": "FNB2026060701234"
    },
    {
      "id": "txn_8e2b10f",
      "date": "2026-06-05",
      "description": "SALARY ACME CORP PTY LTD",
      "amount": 45000.00,
      "running_balance": 15162.50,
      "type": "credit",
      "reference": "FNB2026060500890"
    }
  ]
}

The balance.available field is what the account holder can spend. The running_balance on each transaction is the post-transaction balance at that moment, which lets you reconstruct the full history without ambiguity.

Use case: lender affordability check

A lender wants to verify that an applicant's salary credit appears and that their average month-end balance stays above a threshold.

async function checkAffordability(accountId: string): Promise<AffordabilityResult> {
  const data = await banklink.getTransactions(accountId, { days: 90 });

  const salaryCredits = data.transactions.filter(
    (tx) => tx.type === 'credit' && tx.amount >= 10_000
  );

  const monthEndBalances = getMonthEndBalances(data.transactions);
  const avgMonthEnd = avg(monthEndBalances);

  return {
    regularSalaryDetected: salaryCredits.length >= 2,
    averageMonthEndBalance: avgMonthEnd,
    currentBalance: data.balance.available,
    assessedAt: new Date().toISOString(),
  };
}

No PDF. No phone call. No waiting for payslips by email.

Use case: cash flow dashboard

A business owner wants to see their current balance and a 30-day trend on a dashboard that refreshes automatically.

With BankLink Pulses, you configure a webhook destination that fires every few hours. Your server receives the payload, stores it, and your frontend queries your own database — no polling the bank directly, no rate limits to manage.

// Webhook handler
app.post('/webhooks/banklink', async (req, res) => {
  const { account_number, balance, transactions } = req.body;

  await db.upsertBalance({
    account: account_number,
    available: balance.available,
    ledger: balance.ledger,
    asOf: new Date(),
  });

  await db.insertTransactions(transactions);

  res.sendStatus(200);
});

Your dashboard then reads from your database — always fast, no dependency on the bank being reachable at query time.

Use case: automated payment verification

A business receiving payments wants to confirm that a deposit has landed before releasing an order.

async function hasPaymentLanded(
  accountId: string,
  expectedAmount: number,
  reference: string,
  since: Date
): Promise<boolean> {
  const data = await banklink.getTransactions(accountId, {
    since: since.toISOString(),
    type: 'credit',
  });

  return data.transactions.some(
    (tx) =>
      tx.amount === expectedAmount &&
      tx.description.toLowerCase().includes(reference.toLowerCase())
  );
}

This replaces the manual "check the account and call me when the money is in" workflow that still happens at most SA SMEs.

What to do about polling vs. webhooks

Two patterns exist for keeping your balance data fresh:

Polling — your system requests data on a schedule (every 15 minutes, hourly, daily). Simple to implement. Works even if the upstream source doesn't support push. The tradeoff: you always have some staleness, and you burn requests whether or not anything changed.

Webhooks (Pulses) — the bank connectivity layer pushes data to you when a sync runs. Your system is reactive rather than polling. BankLink Pulses work this way: you set a schedule, BankLink syncs on that schedule and posts the result to your endpoint.

For most financial use cases, a Pulse running every 4–6 hours is enough. For real-time payment confirmation, you can trigger a manual sync via the API and check the result.

Handling stale data gracefully

Because SA bank data arrives on a pull schedule rather than real-time push, your application should always show users when the data was last refreshed.

function BalanceCard({ account }: { account: AccountData }) {
  const minutesAgo = Math.floor(
    (Date.now() - new Date(account.synced_at).getTime()) / 60_000
  );

  return (
    <div>
      <p>R {account.balance.available.toLocaleString('en-ZA')}</p>
      <p className="text-muted">
        Updated {minutesAgo < 60
          ? `${minutesAgo}m ago`
          : `${Math.floor(minutesAgo / 60)}h ago`}
      </p>
    </div>
  );
}

Never show a balance without a timestamp. A balance from 6 hours ago can look identical to a real-time balance — and the user needs to know the difference.

Getting started

BankLink handles FNB account connectivity today. Link an account, configure a Pulse, and your webhook starts receiving structured balance and transaction data on your chosen schedule.

Try BankLink → app.banklink.co.za