I wanted to read real bank balances and transactions from Dart. GoCardless has the API for exactly that, its Bank Account Data API. Then I read the flow.
Request an access token and refresh it before it expires, discover institutions, create an end-user agreement, open a requisition (the bank-linking consent flow), push the user through their bank’s consent screen, poll until the link confirms, then fan out over the returned account ids for balances, details, and transactions. Done by hand, you hand-roll the token lifecycle, memorize the wire field names, and decode a different error envelope on every call. It adds up.
So I wrapped it. gcbad_dart is a strongly-typed Dart client that folds the whole flow behind one client, and it is on pub.dev now: version 1.0.1, MIT-licensed, pure Dart across all six platforms.
Here is the live 1.0.1 page, fresh off publish:

What you’d build with it
Anything that needs a read-only, consented view of someone’s real accounts is a fit. A few concrete ones:
- A budgeting app: read the booked and pending transactions on each account, bucket them, and the month’s spending explains itself.
- A net-worth tracker: fan out over the linked accounts, sum the balances, and lean on account details for the labels.
- A subscription finder: walk the transaction history for creditors that recur on a monthly cadence, then surface the ones someone forgot they were paying.
- A cash-flow forecaster: current balances plus a date-ranged transaction pull, projected forward a few weeks.
The linking flow is the same in every case. The interesting part is what you do with the data once it lands.
The flow, in a handful of calls
Point the client at your secret id and key and the consent flow collapses to a short, typed sequence. No /token/new/, no /token/refresh/: the bearer token is requested on first use and refreshed before it expires, underneath, where you never see it.
import 'package:gcbad_dart/gcbad_dart.dart';
Future<void> main() async {
final client = GoCardlessBankAccountDataClient(
secretId: 'your-secret-id',
secretKey: 'your-secret-key',
);
final institution = await client.getSandboxInstitution();
final agreement = await client.createDefaultAgreement(institution);
final requisition = await client.createRequisition(agreement);
print('Authenticate here: ${requisition.link}');
final linked = await client.waitForRequisitionLink(requisition);
for (final account in await client.getAccounts(linked)) {
final balances = await client.getBalances(account);
print('${account.ownerName}: ${balances.balances.length} balances');
}
}
Every name in there is a real method returning a generated model. createDefaultAgreement and createRequisition get you into the consent flow in two calls, with fully-parameterized overloads waiting when you need control. waitForRequisitionLink polls until the requisition status is linked (which maps to the wire value 'LN'), so you never script the wait loop.
One exception, every failure
The part I care about most is the boring one. Every failure, an HTTP error, a JSON parse failure, or a GoCardless error payload, surfaces as a single GoCardlessException, built from GoCardless’s own summary, detail, and status_code. Your call sites catch one type, never a raw HTTP or decode error:
try {
await client.getInstitutionById('does-not-exist');
} on GoCardlessException catch (e) {
print(e.message);
}
Every call hands back a typed model or throws a single exception. There is no third case.
That holds because there is exactly one place in the whole client where parsing and error handling happen.
Three layers, one job each
That one place is the reason the exception guarantee is even possible. GoCardlessBankAccountDataClient is the public surface: ergonomic methods, orchestration like getAccounts and waitForRequisitionLink, model-to-JSON encoding, and date formatting. It sits on GoCardlessHttpClient, one method per endpoint, holding and refreshing the token. That sits on GoCardlessHttpUtils, the single parse and error chokepoint that decodes each response, runs the model’s fromJson, and on any failure re-reads the body as an error and throws. Responses decode as UTF-8, so non-ASCII fields round-trip intact.
The whole thing rests on two runtime dependencies, http and json_annotation, and nothing else.
What it does not do
gcbad_dart is a thin, focused client for the v2 Bank Account Data API, not an official SDK. The live end-to-end path still needs real GoCardless credentials and a browser for the human consent step, because a person genuinely has to approve access at their bank. And it covers bank account data, not GoCardless’s payments or direct-debit APIs.
The trade I made: give up breadth and keep one job sharp. You get the full v2 flow behind typed calls, one exception to catch, and a Dart SDK constraint of ^3.9.0 with two dependencies to audit. Install it with dart pub add gcbad_dart.
It is MIT-licensed and open, so issues and pull requests are welcome on GitHub. More of what I build lives at lezli01.is-a.dev.