<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: John Frandsen</title>
    <description>The latest articles on DEV Community by John Frandsen (@johnfrandsen).</description>
    <link>https://dev.to/johnfrandsen</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4007231%2Fa937353d-df18-4018-98c4-1ea581821a38.png</url>
      <title>DEV Community: John Frandsen</title>
      <link>https://dev.to/johnfrandsen</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/johnfrandsen"/>
    <language>en</language>
    <item>
      <title>Tink Alternatives in 2026: An Honest Developer's Comparison</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Sat, 29 Aug 2026 01:10:59 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/tink-alternatives-in-2026-an-honest-developers-comparison-3mcp</link>
      <guid>https://dev.to/johnfrandsen/tink-alternatives-in-2026-an-honest-developers-comparison-3mcp</guid>
      <description>&lt;p&gt;Most people searching for a Tink alternative aren't angry at Tink. The coverage is real, the docs are decent, and the API does what it says. The search usually happens for a simpler reason: Tink was bought by Visa in 2022, and the product has drifted upmarket ever since.&lt;/p&gt;

&lt;p&gt;I've spent the past two years wiring European bank data into small apps, almost always through an aggregator. Here's my honest take on when it makes sense to move off Tink, and what you actually get when you do.&lt;/p&gt;

&lt;p&gt;A note on method: everything below comes from pointing the same small app — list accounts, pull transactions nightly, render a balance — at several of these providers during 2026. I judge them on four things: whether the sandbox works without a signed contract, whether they cover the banks my users actually have, whether pricing survives having fewer than a few thousand users, and how much the consent flow hurts on a phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why developers start looking
&lt;/h2&gt;

&lt;p&gt;Tink's positioning today is an enterprise platform: risk scoring, payment initiation, and account verification sold as bundles to banks and large fintechs. If that's you, none of this is a problem.&lt;/p&gt;

&lt;p&gt;If you're a two-person team that just wants to list transactions, the friction shows up early:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pricing isn't public, so budgeting starts with a sales call rather than a docs page.&lt;/li&gt;
&lt;li&gt;The platform is bundled — you're sold payments and risk products alongside the transactions API you actually asked for.&lt;/li&gt;
&lt;li&gt;Going from sandbox to production involves commercial review, which is where side projects go to die.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this makes Tink bad. It makes Tink a poor fit for anything smaller than a funded company that needs read-only account data this month, not next quarter.&lt;/p&gt;

&lt;p&gt;There's also a quieter reason teams move: roadmap risk. When a platform's priorities are set inside a card network, features get built for the network's enterprise needs. Transactions-as-a-commodity for small apps is not that, and it shows in what ships and what gets deprecated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The alternatives, honestly
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;GoCardless Bank Account Data (formerly Nordigen).&lt;/strong&gt; Published pricing, a genuine free sandbox, and solid EU coverage. The catch is availability: signups have been gated in waves since the Nordigen brand was retired, so confirm you can actually get in before you build on it. If you can, it's the default budget choice for EU bank data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TrueLayer.&lt;/strong&gt; The strongest UK coverage of the independents and the best payment initiation story. It's also the most enterprise-leaning of the bunch — expect a real sales process before production keys. I wrote a longer piece on &lt;a href="https://dev.to/johnfrandsen/beyond-truelayer-choosing-the-right-open-banking-api-for-european-projects-5coa"&gt;choosing an open banking API beyond TrueLayer&lt;/a&gt; that goes deeper on when the paperwork is worth it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Yapily.&lt;/strong&gt; API-first with no dashboard hand-holding, per-call pricing you can at least see, and wide coverage. Good fit if you're comfortable doing more of the integration yourself and don't want to be sold a platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plaid (in Europe).&lt;/strong&gt; It works, but you're using their home-market product transplanted across the Atlantic. European bank coverage lags the locals, and per-item pricing in dollars gets odd when your users are all in euros.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The self-hosted route.&lt;/strong&gt; For small tools this is where I've landed. I maintain open-banking.io, a self-hosted PSD2 aggregator where you hold your own private key so the operator can't read your bank data, and a hobby-scale setup runs around €3/month. The trade is breadth for control: coverage is EU/UK, not global, and you're the one holding the keys in every sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Switching costs less than you expect
&lt;/h2&gt;

&lt;p&gt;Every provider above normalizes banks into something close to the Berlin Group shapes, so your core integration is one small family of endpoints: consent, accounts, transactions. Here's the entire read path against most of them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.aggregator.example/v1/accounts/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/transactions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;access_token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date_from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-06-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date_to&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-08-28&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transactions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;booked&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transactionAmount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;note&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;remittanceInformationUnstructured&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bookingDate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;note&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap the base URL and the auth header, and that snippet runs against any aggregator in this list. I've ported this exact loop between three providers; the GET was never the hard part.&lt;/p&gt;

&lt;p&gt;The hard part is everything around it: paginating a first sync of several thousand transactions, deduplicating overlaps between windows, and surviving the 90-day consent re-authorization cycle that PSD2 imposes. That pipeline is identical regardless of vendor, and I wrote a &lt;a href="https://dev.to/johnfrandsen/how-to-sync-bank-transactions-with-open-banking-apis-2026-guide-57n1"&gt;full sync guide with the failure modes&lt;/a&gt; if you're heading there.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually choose
&lt;/h2&gt;

&lt;p&gt;Rank your banks before you rank your providers. Coverage is the one thing you can't engineer around: if the three banks most of your users sit at aren't well supported, nothing else about a provider matters. Every aggregator here publishes a bank directory — spend ten minutes in it before anything else, because coverage claims in marketing pages and coverage in the directory rarely match perfectly.&lt;/p&gt;

&lt;p&gt;Then match the pricing model to your app's shape. Per-connection pricing hurts when your users reconnect often or churn through sandboxes; per-call pricing hurts when you poll nightly for a handful of transactions; minimum commitments hurt everyone under a few thousand users. The cheapest provider for a personal finance tool is rarely the cheapest for a B2B dashboard, and vice versa.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Tink is still the right answer
&lt;/h2&gt;

&lt;p&gt;If you need payment initiation at volume across many markets, or you want risk and verification products from a single vendor that a bank regulator will recognize, Tink's enterprise direction is a feature, not a bug. A funded fintech should still shortlist it. So should anyone who needs the reassurance of a Visa-owned counterparty in their procurement chain.&lt;/p&gt;

&lt;p&gt;For everyone else — the side project, the personal finance tool, the small business dashboard — the instinct that brought you to this search is correct. Just do one thing before you commit: test a real connection to the specific banks your users actually have. Coverage charts look identical on paper across every provider here, and the differences only show up the first time you hit a real bank redirect flow with real credentials.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I maintain open-banking.io, one of the options discussed above. Weigh my bias accordingly.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openbanking</category>
      <category>psd2</category>
      <category>fintech</category>
      <category>api</category>
    </item>
    <item>
      <title>Open Banking Python Library: What Actually Exists in 2026 (and What I Use Instead)</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Fri, 28 Aug 2026 11:11:20 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/open-banking-python-library-what-actually-exists-in-2026-and-what-i-use-instead-1mfe</link>
      <guid>https://dev.to/johnfrandsen/open-banking-python-library-what-actually-exists-in-2026-and-what-i-use-instead-1mfe</guid>
      <description>&lt;p&gt;When I first needed bank transactions inside a Python app, I did what every developer does. I typed &lt;code&gt;pip install open-banking&lt;/code&gt; and hoped for the best. What I found instead was a drawer full of half-maintained wrappers, each tied to one commercial provider. This is the map I wish somebody had handed me back then.&lt;/p&gt;

&lt;p&gt;Quick disclosure so you can calibrate my bias: I maintain open-banking.io, a low-cost EU/UK bank-data API. This piece is about the whole landscape, not one product.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a search for an open banking Python library actually finds
&lt;/h2&gt;

&lt;p&gt;Search PyPI for "open banking" and you get a small pile of packages. The one literally named &lt;code&gt;openbankingapi&lt;/code&gt; is thin and hasn't seen meaningful work in years. &lt;code&gt;enablebanking-api&lt;/code&gt; is a generated SDK for Enable Banking's commercial API. There are also several community wrappers for the old Nordigen API, none of them official, and most went quiet after GoCardless absorbed Nordigen and rebranded everything under Bank Account Data.&lt;/p&gt;

&lt;p&gt;The bigger aggregators do ship real SDKs. Plaid has &lt;code&gt;plaid-python&lt;/code&gt;, maintained and fine, but its European coverage has always felt like the B-side of a US-first product. TrueLayer's &lt;code&gt;truelayer-python&lt;/code&gt; is decent too, though the company has drifted steadily upmarket toward enterprise clients.&lt;/p&gt;

&lt;p&gt;None of these packages is &lt;em&gt;wrong&lt;/em&gt;. But none of them is what "open banking Python library" sounds like either. They're all clients for one company's REST API, and the company can vanish or reprice right underneath them. Ask anyone who built on the Nordigen free tier.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why there's no "requests" of open banking
&lt;/h2&gt;

&lt;p&gt;The dream library would talk to every bank directly. In Europe that can't exist, and the reason is regulatory more than technical.&lt;/p&gt;

&lt;p&gt;PSD2 forces thousands of banks to expose APIs, but each bank implements its own flavor, and calling them as a third party requires an eIDAS certificate most indie devs will never want to obtain. So the market settled on aggregators: licensed intermediaries that maintain one clean REST API over hundreds of banks. All the ugly per-bank work happens on their servers.&lt;/p&gt;

&lt;p&gt;That's why every Python package you find wraps exactly one aggregator. The aggregator &lt;em&gt;is&lt;/em&gt; the library. Python is just the steering wheel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three realistic options
&lt;/h2&gt;

&lt;p&gt;Given that, you really have three choices:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use your provider's official SDK if one exists. Fine, but you inherit their abstractions and their release cadence.&lt;/li&gt;
&lt;li&gt;Use a community wrapper. Check the last-commit date first; half of these are weekend projects wearing a README.&lt;/li&gt;
&lt;li&gt;Write ~60 lines of &lt;code&gt;requests&lt;/code&gt; against the provider's REST API yourself. My usual pick. You understand every line, and switching providers later becomes a base-URL change instead of a rewrite.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I've done all three on real projects. The third ages best.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal client in plain requests
&lt;/h2&gt;

&lt;p&gt;Most aggregator APIs follow the same shape: list banks, start a consent flow, read accounts, read transactions. Here's the entire pattern, anonymized:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.your-ais-provider.example/v2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;H&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer YOUR_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;# from the provider's dashboard
&lt;/span&gt;
&lt;span class="c1"&gt;# 1. Find the user's bank
&lt;/span&gt;&lt;span class="n"&gt;banks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/banks/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;bank_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;banks&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;YourBank&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# 2. Start a consent (requisition) flow — user approves at their bank
&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/requisitions/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redirect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:8000/callback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;institution_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bank_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Send the user to:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;link&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="c1"&gt;# 3. After they approve and land back on your redirect:
&lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/requisitions/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accounts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;tx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/accounts/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/transactions/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the core loop for basically every European open banking provider. If a library saves you less code than this, ask what it's actually adding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part no library saves you from
&lt;/h2&gt;

&lt;p&gt;The loop above is the easy 20%. The annoying 80% is lifecycle management, and no pip package handles it for you.&lt;/p&gt;

&lt;p&gt;PSD2 consents expire — in most countries the user has to re-authenticate roughly every 90 days. Bank connections also break for mundane reasons: password changes, bank maintenance windows, silent schema drift on one specific bank's transaction format. You'll want a small job that checks connection health and tells the user &lt;em&gt;which&lt;/em&gt; bank needs attention, in plain language.&lt;/p&gt;

&lt;p&gt;Refresh tokens are the other quiet time sink. They expire on their own schedule, per provider, and the failure mode is always a 401 at 2am rather than a clean error. Whatever you build, wrap it in one retry-with-re-auth path early. That's the code I wish I'd written on day one instead of week six.&lt;/p&gt;

&lt;p&gt;This is exactly where writing your own thin module pays off. The failure modes are &lt;em&gt;your&lt;/em&gt; product decisions, not the library's.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I check before depending on any of this
&lt;/h2&gt;

&lt;p&gt;Before committing to a provider, and by extension to a library, I look at four things.&lt;/p&gt;

&lt;p&gt;Bank coverage in the countries I actually need. Aggregate bank counts are marketing. The list for your two target countries is reality.&lt;/p&gt;

&lt;p&gt;Whether the sandbox works without paperwork. If I can't test end-to-end before talking to a sales team, that tells me everything about who the product is really for.&lt;/p&gt;

&lt;p&gt;Pricing per bank connection. This varies wildly, from enterprise contracts down to entry tiers around €3/month at the low end. For an indie project, this number decides viability more than any API design choice.&lt;/p&gt;

&lt;p&gt;Who holds the keys. Some providers keep everything server-side; others let you hold your own private key so plaintext account data never touches their infrastructure. Worth knowing that option exists before you need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd pick
&lt;/h2&gt;

&lt;p&gt;If you just want to see it work this weekend, pick a provider with a no-friction sandbox and write the 60 lines of requests yourself. I wrote a longer walkthrough of doing exactly that with Python — no certificates, real transactions at the end — if you want the full version: &lt;a href="https://dev.to/johnfrandsen/reading-eu-bank-transactions-with-python-and-psd2-without-buying-an-eidas-certificate-io3"&gt;reading EU bank transactions with Python and PSD2&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Then wrap those calls in one small module of your own. Congratulations, you now have a private open banking Python library. One you fully control, and one that survives whatever the aggregator market decides to do next.&lt;/p&gt;

</description>
      <category>python</category>
      <category>openbanking</category>
      <category>banking</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Plaid Alternatives in Europe - What Actually Changes When You Switch</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Mon, 17 Aug 2026 01:21:45 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/plaid-alternatives-in-europe-what-actually-changes-when-you-switch-5058</link>
      <guid>https://dev.to/johnfrandsen/plaid-alternatives-in-europe-what-actually-changes-when-you-switch-5058</guid>
      <description>&lt;p&gt;If you're building in the EU or UK and Plaid's per-connection pricing, US data flows, or sandbox friction is pushing you elsewhere, this is what the realistic alternatives look like in 2026 — and the migration gotchas nobody puts on their landing page.&lt;/p&gt;

&lt;p&gt;I've spent the last year neck-deep in PSD2 bank APIs (I maintain open-banking.io, a small EU aggregator), so this is written from the integration side, not the vendor-marketing side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why teams actually switch
&lt;/h2&gt;

&lt;p&gt;The reasons I hear most, roughly in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pricing opacity at scale.&lt;/strong&gt; Plaid bills per connected item. Once you're past a few thousand users doing monthly refreshes, the math starts to hurt — and you can't easily model it because success-based billing depends on login performance you don't control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data residency and jurisdiction.&lt;/strong&gt; EU companies increasingly need EU-processed data flows for procurement or GDPR posture. A US-headquartered aggregator is a harder internal sell than it was five years ago.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/johnfrandsen/open-banking-without-an-eidas-certificate-a-practical-developers-guide-2026-1j6"&gt;The certificate question&lt;/a&gt;.&lt;/strong&gt; Some teams skip aggregators entirely and go direct-to-bank with their own AISP license — then discover what an eIDAS QWAC certificate costs in money and operational burden, and come back to aggregator APIs. The middle ground (an aggregator that doesn't push certificates onto you) is where most end up.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The realistic options
&lt;/h2&gt;

&lt;p&gt;A few notes on the main European players, from an integration perspective:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GoCardless (Bank Account Data, ex-Nordigen).&lt;/strong&gt; The default first stop — free-ish tier, huge bank coverage, and the end of the "Nordigen is free" era. Pricing isn't published on a public list for the bank-data product; expect a sales conversation. Their API is the Nordigen API you may already know: requisitions, institutions, transactions with &lt;code&gt;interimBooked&lt;/code&gt;/&lt;code&gt;booked&lt;/code&gt; buckets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enable Banking.&lt;/strong&gt; A solid Finnish aggregator with strong Nordic/Baltic coverage. Sandbox access is self-serve on signup; production pricing is quote-based. One thing to know: their model leans on eIDAS certificate machinery on their side, which they've turned into a managed service — good if you want that handled, but ask how much of the cert lifecycle you inherit operationally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TrueLayer.&lt;/strong&gt; The biggest EU name in the space. Their public pricing page is gone — it's a sales-led motion now. Great coverage and docs; overkill if you just need account information for a small app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tink (Visa).&lt;/strong&gt; Enterprise-grade, enterprise-priced, and after Visa's acquisition the go-to-market is focused on banks and large platforms. If you're a two-person startup, this probably isn't your lane.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Salt Edge.&lt;/strong&gt; Strong if you need coverage beyond EU/UK (they shine in Eastern Europe, MENA, and parts of Asia). Emulator-based connections make coverage claims broader than pure-PSD2 players.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;open-banking.io&lt;/strong&gt; (that's mine): API-key auth with no certificates to manage, €3/month for your first account and €1/month for each additional (VAT included), zero-knowledge — your users hold their own keys, so the platform can't read their data. Coverage is the standard EU/UK PSD2 set. It's small and honest about being small.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part nobody tells you: migrating connection semantics
&lt;/h2&gt;

&lt;p&gt;This is where Plaid → PSD2 migrations actually hurt. It's not auth flows or REST clients — it's that the object model is different in ways that break your sync logic.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plaid has Items; PSD2 has consents.&lt;/strong&gt; A Plaid Item is stable until the user revokes it. A PSD2 consent expires — typically after 90 days for SCA-regulated refreshes, sometimes shorter depending on the bank and the initial consent scope. Your sync scheduler needs consent-expiry awareness or you'll wake up to a wall of 401s every quarter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reauthentication mints new identifiers.&lt;/strong&gt; When a PSD2 consent lapses and the user re-authenticates, many providers issue a new connection/requisition ID for the same real-world bank link. If you keyed your users table on connection ID, you now have duplicates. Key on bank + account number instead, and treat connection IDs as mutable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Transactions are eventually consistent.&lt;/strong&gt; Plaid's webhooks are close to reliable; PSD2 access-provider webhooks range from "decent" to "decorative." Poll on a schedule, treat webhooks as a nudge, and dedupe on the bank's stable transaction ID. Pending → booked transitions are an UPDATE, not an INSERT — Plaid models this with pending_transaction_id, and you'll want the same discipline with PSD2's interimBooked → booked flow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limits are bank-imposed.&lt;/strong&gt; The ~4 unattended pulls per day per consent isn't an aggregator policy — it's what several ASPSPs allow for background access without fresh SCA. Budget your refresh cadence around it: a 90-day rolling re-fetch window with dedupe gets you everything a daily full pull would, at a fraction of the budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  A quick comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Auth model&lt;/th&gt;
&lt;th&gt;Pricing posture&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GoCardless (ex-Nordigen)&lt;/td&gt;
&lt;td&gt;API keys&lt;/td&gt;
&lt;td&gt;Sales conversation&lt;/td&gt;
&lt;td&gt;Huge coverage, the default stop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enable Banking&lt;/td&gt;
&lt;td&gt;eIDAS (managed on their side)&lt;/td&gt;
&lt;td&gt;Quote-based&lt;/td&gt;
&lt;td&gt;Strong Nordics&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TrueLayer&lt;/td&gt;
&lt;td&gt;OAuth flows&lt;/td&gt;
&lt;td&gt;Sales-led&lt;/td&gt;
&lt;td&gt;Biggest EU name&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tink&lt;/td&gt;
&lt;td&gt;OAuth flows&lt;/td&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;Bank-grade, bank-priced&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Salt Edge&lt;/td&gt;
&lt;td&gt;API keys&lt;/td&gt;
&lt;td&gt;Quote-based&lt;/td&gt;
&lt;td&gt;Best non-EU coverage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;open-banking.io&lt;/td&gt;
&lt;td&gt;API keys, zero-knowledge&lt;/td&gt;
&lt;td&gt;€3/mo first account, +€1/mo each, VAT incl&lt;/td&gt;
&lt;td&gt;No certificates&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How to choose without a sales cycle
&lt;/h2&gt;

&lt;p&gt;My honest advice for a small team:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;If you're doing under ~500 connected accounts and just need EU/UK account information: start with the cheapest self-serve option that doesn't require a call. You can always migrate again — the second migration is easier because you'll have already fixed your connection-keying and consent-expiry handling.&lt;/li&gt;
&lt;li&gt;If you're going to production volume: get two quotes and pressure-test the per-connection cost at your realistic refresh cadence, not your current user count.&lt;/li&gt;
&lt;li&gt;Whatever you pick, write your sync layer provider-agnostic from day one: consent expiry, mutable connection IDs, stable-transaction-ID dedupe. That's 90% of what makes a future switch cheap.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Switching away from Plaid in Europe is genuinely reasonable in 2026 — the PSD2 rails are mature. Just budget for the semantics migration, not just the API client swap.&lt;/p&gt;

</description>
      <category>fintech</category>
      <category>openbanking</category>
      <category>psd2</category>
      <category>europe</category>
    </item>
    <item>
      <title>Free Signup vs Production Access: What Self-Serve PSD2 Providers Actually Gate</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Sun, 16 Aug 2026 01:15:35 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/free-signup-vs-production-access-what-self-serve-psd2-providers-actually-gate-306p</link>
      <guid>https://dev.to/johnfrandsen/free-signup-vs-production-access-what-self-serve-psd2-providers-actually-gate-306p</guid>
      <description>&lt;h2&gt;
  
  
  "Self-serve" is doing a lot of work
&lt;/h2&gt;

&lt;p&gt;Every PSD2 account-information provider markets some version of "get started in minutes." What varies — enormously — is what you can actually do before a human from sales gets involved. There are really three gates:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Signup&lt;/strong&gt; — can you create an account at all, alone, with an email address?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sandbox&lt;/strong&gt; — can you call working APIs before signing anything?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production&lt;/strong&gt; — can real users at real banks connect, and under what terms?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A provider can be wide open at gates one and two and still be effectively closed at gate three. That's the trap, and it's why "self-serve PSD2" comparisons deserve more than a feature checkbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  The field as of August 2026
&lt;/h2&gt;

&lt;p&gt;(From actually attempting the signups, not from reading landing pages.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enable Banking&lt;/strong&gt; is &lt;a href="https://dev.to/johnfrandsen/comparing-european-open-banking-api-providers-in-2026-plaid-truelayer-tink-gocardless-125c"&gt;the closest thing to genuine self-serve among the challengers&lt;/a&gt;. Email magic-link signup, account auto-created, a sandbox with no contract — and, notably, &lt;strong&gt;restricted production access with no contract either&lt;/strong&gt;, where you can connect your own accounts. Their sandbox is genuinely good: real bank coverage, honest documentation, no credit card. Credit where due.&lt;/p&gt;

&lt;p&gt;But the moment your app serves other people — public, customer-facing production — you need a &lt;strong&gt;signed contract, KYB (know-your-business) verification, and volume-based billing priced by their sales team&lt;/strong&gt; via &lt;a href="mailto:info@enablebanking.com"&gt;info@enablebanking.com&lt;/a&gt;. Perfectly reasonable for a business with a finance function. Not the "self-serve" an indie builder was hoping for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GoCardless Bank Account Data&lt;/strong&gt; (formerly Nordigen) — &lt;a href="https://dev.to/johnfrandsen/nordigen-alternatives-in-2026-a-developers-guide-to-european-bank-data-apis-3pie"&gt;closed to new onboarding since 2025, free tier sunset&lt;/a&gt;. The platform that built a thousand side projects no longer takes new independent developers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TrueLayer&lt;/strong&gt; — free tier gone, /pricing 404s, and a clear retreat upmarket to enterprise. The live-upgrade path involves sending your client_id to sales.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tink&lt;/strong&gt; (Visa) — "Contact Sales" on effectively every page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Yapily&lt;/strong&gt; — "Talk to an expert." Same wall.&lt;/p&gt;

&lt;p&gt;None of this is misconduct. These are businesses that did the math and stopped subsidizing hobbyists. But if you ARE the hobbyist, the practical reading is: the 2021-era free-open-banking window has closed at the big platforms.&lt;/p&gt;

&lt;h2&gt;
  
  
  The table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Signup&lt;/th&gt;
&lt;th&gt;Sandbox&lt;/th&gt;
&lt;th&gt;Prod (own accounts)&lt;/th&gt;
&lt;th&gt;Prod (customers)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Enable Banking&lt;/td&gt;
&lt;td&gt;Self-serve, magic link&lt;/td&gt;
&lt;td&gt;Free, no contract&lt;/td&gt;
&lt;td&gt;Restricted, no contract&lt;/td&gt;
&lt;td&gt;Contract + KYB + sales-priced volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GoCardless Bank Account Data&lt;/td&gt;
&lt;td&gt;Closed to new users&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TrueLayer&lt;/td&gt;
&lt;td&gt;Sales-assisted&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;td&gt;Via sales (send client_id)&lt;/td&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tink&lt;/td&gt;
&lt;td&gt;"Contact Sales"&lt;/td&gt;
&lt;td&gt;Gated&lt;/td&gt;
&lt;td&gt;Sales&lt;/td&gt;
&lt;td&gt;Sales&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yapily&lt;/td&gt;
&lt;td&gt;"Talk to an expert"&lt;/td&gt;
&lt;td&gt;Gated&lt;/td&gt;
&lt;td&gt;Sales&lt;/td&gt;
&lt;td&gt;Sales&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;open-banking.io&lt;/td&gt;
&lt;td&gt;Self-serve&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes — your own bank keys&lt;/td&gt;
&lt;td&gt;Yes, from day one (~€3/mo)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How to check the gate yourself in ten minutes
&lt;/h2&gt;

&lt;p&gt;Before you commit a weekend of integration work to any provider, run this diligence pass. It's what produced the table above.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find the pricing page.&lt;/strong&gt; Does it exist? A 404 on /pricing tells you the company has stopped caring about small buyers — the funnel starts with a sales call now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Try the signup with a throwaway email.&lt;/strong&gt; Magic link in your inbox within a minute = real self-serve. A "we'll be in touch" form = sales-walled, whatever the landing page says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search the docs for "production" or "go-live".&lt;/strong&gt; Read what the upgrade requires. If the words "contract", "KYB", or "contact our team" appear, budget weeks, not minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check who holds the keys.&lt;/strong&gt; Do you register with banks yourself and use your own client credentials, or does the provider hold everything? Own keys means you can leave later without re-consenting every user through yet another provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask what a certificate costs you.&lt;/strong&gt; If the answer is "nothing, our license covers it", that's the cert-free model. If you're expected to produce a QWAC, that's €2–10k a year you hadn't budgeted.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ten minutes of this beats three weekends of integration into a platform you'll have to leave.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision framework
&lt;/h2&gt;

&lt;p&gt;The real question isn't "who's best" — it's "which gate model does my project need?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Restricted production (own accounts only) is enough when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're building a personal tool — your own budgeting dashboard, net-worth tracker, tax-prep helper&lt;/li&gt;
&lt;li&gt;You're evaluating integrations before committing&lt;/li&gt;
&lt;li&gt;The only bank accounts that will ever pass through the app are yours&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In that world, Enable Banking's model is genuinely a good deal: free, no contract, your real data. I'd use it without hesitation for a personal project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You need public production the moment:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anyone who isn't you connects a bank&lt;/li&gt;
&lt;li&gt;You charge money for the product&lt;/li&gt;
&lt;li&gt;You hand it to a beta group, a friend, or a client&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The instant "any real user" exists, restricted-prod models snap you into contracts, KYB paperwork, and sales cycles measured in weeks. Fine if you're at that stage and want negotiated volume pricing. If you're not, it's friction between you and your first ten users — which is where most side projects die.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Price in the certificate tax, too.&lt;/strong&gt; Going direct rather than through any aggregator usually means eIDAS QWAC/QSealC certificates: €2–10k a year and months of paperwork before your first API call. Some aggregator models (mine included) are structured so the aggregator's license covers that and you never touch a certificate. Know which model you're signing up for before you spend a weekend on integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Disclosure
&lt;/h2&gt;

&lt;p&gt;I build open-banking.io — a certificate-free EU/UK PSD2 account-information API. Self-serve signup; you use your own bank-issued client keys, with the aggregator-style license handled for you; about €3/month; new customers are welcome, with public production access from day one. I'm obviously biased, which is why the table above describes competitors as accurately as I can manage — including Enable Banking's genuinely free tier, which fits some of these use-cases better than mine does.&lt;/p&gt;

&lt;p&gt;Match the gate model to the use-case. That's the whole article.&lt;/p&gt;

</description>
      <category>openbanking</category>
    </item>
    <item>
      <title>Migrating Off Nordigen: A Field Guide for Indie Builders (2026)</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Sat, 15 Aug 2026 01:12:36 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/migrating-off-nordigen-a-field-guide-for-indie-builders-2026-4o3j</link>
      <guid>https://dev.to/johnfrandsen/migrating-off-nordigen-a-field-guide-for-indie-builders-2026-4o3j</guid>
      <description>&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;The short version: the era of free bank data for side projects is over at the big aggregators.&lt;/p&gt;

&lt;p&gt;Nordigen — the Latvian open-banking startup that ran a genuinely free account-information API — was acquired by GoCardless in 2022 and is being folded into "GoCardless Bank Account Data". &lt;a href="https://dev.to/johnfrandsen/the-cheapest-open-banking-apis-for-small-businesses-and-indie-builders-in-2026-5cab"&gt;The free tier has been sunset&lt;/a&gt;. And GoCardless Bank Account Data itself stopped taking new onboarding back in 2025. As of this writing, there's no indication that access is coming back for new independent developers.&lt;/p&gt;

&lt;p&gt;If you built on Nordigen, you already know all this. If you were about to, this article saves you an afternoon of dead-end signup pages.&lt;/p&gt;

&lt;p&gt;Who's actually affected:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Budgeting apps&lt;/strong&gt; pulling balances and transactions nightly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expense trackers&lt;/strong&gt; categorizing card spending&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spreadsheet users&lt;/strong&gt; pushing transactions into Google Sheets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indie SaaS&lt;/strong&gt; with a "connect your bank" button that a few hundred real people use&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All of these ran fine on a free tier. None of them have a procurement department.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your realistic options
&lt;/h2&gt;

&lt;p&gt;Three paths, roughly ordered by pain.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Direct PSD2 with your own eIDAS certificates
&lt;/h3&gt;

&lt;p&gt;You register as an account information service provider, get through the paperwork, and obtain QWAC and QSealC certificates from a qualified trust service provider. Then you integrate with each bank's API yourself.&lt;/p&gt;

&lt;p&gt;Real costs, as of 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;QWAC + QSealC certificates: €2,000–€10,000 per year&lt;/strong&gt;, depending on the trust service provider&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Months of paperwork&lt;/strong&gt; — audit evidence, policy documents, insurance, registration processes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-bank overhead&lt;/strong&gt; — some banks require their own onboarding and certificates on top&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permanent maintenance&lt;/strong&gt; — you own every bank's API quirks and breaking changes forever&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes sense if bank connectivity IS your product. It does not make sense for a budgeting app with 40 users.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Aggregator with your own bank-issued keys (the cert-free middle path)
&lt;/h3&gt;

&lt;p&gt;Some aggregators handle the licensing and certificate burden themselves and let you bring your own bank-issued client credentials. You register with each bank yourself (usually a web form, often same-day approval), drop the client ID/secret into the aggregator's API, and get one harmonized REST interface across banks. The aggregator does the certificate dance; you never see a QWAC.&lt;/p&gt;

&lt;p&gt;You keep the keys, which means you can always go direct later. This is where most ex-Nordigen indies land.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Full broker — the aggregator holds everything
&lt;/h3&gt;

&lt;p&gt;The classic enterprise model: the aggregator holds the license, the certificates, and the bank relationships. You never touch a bank credential. In exchange you get sales calls, per-call pricing tiers, and contract cycles. Fine for fintechs; heavy for side projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration mechanics
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Consents do not travel
&lt;/h3&gt;

&lt;p&gt;A PSD2 consent is best understood as OAuth-for-bank-data: the bank issues it to a specific account-information provider, for a specific user, for a limited window. When you switch providers, the consent stays behind. &lt;strong&gt;There is no token export.&lt;/strong&gt; Every end-user re-authorizes through the new provider's flow.&lt;/p&gt;

&lt;p&gt;Practical consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every user re-authenticates once. Plan your comms; expect a dip in connected accounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 90-day clock resets.&lt;/strong&gt; Recurring-access consents under PSD2 last up to 90 days (180 in some jurisdictions as rules evolve — plan for 90). After migration, day one is day one again.&lt;/li&gt;
&lt;li&gt;Nordigen "requisitions" and "end-user agreements" can't be moved or converted. They die with the account.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Historical data: export before you cancel
&lt;/h3&gt;

&lt;p&gt;Most banks serve around 90 days of transaction history per consent window through the API. Some serve more; few serve years. If your old provider cached more history than that, &lt;strong&gt;export it before you deactivate the account&lt;/strong&gt; — raw accounts, balances, transactions, metadata — into storage you control. Don't count on re-fetching two years of history through the new provider; the bank may simply not serve it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Dedupe: the stable transaction ID
&lt;/h3&gt;

&lt;p&gt;Most banks give transactions a stable ID. Use it as your dedupe key — but hash it together with the booking date:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dedupe_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;transaction_id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;|&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;txn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;booking_date&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why hash ID + booking date? Because some banks re-issue the same ID for a corrected transaction with a different date, and you want that to land as a new row, not get swallowed.&lt;/p&gt;

&lt;p&gt;Then watch &lt;strong&gt;booked vs pending&lt;/strong&gt;. The same logical transaction typically shows up first as &lt;code&gt;pending&lt;/code&gt; and later as &lt;code&gt;booked&lt;/code&gt;. Some banks give both entries the same ID; some don't. Two rules that save you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Ingest pending and booked into a &lt;strong&gt;state column&lt;/strong&gt; (or separate paths), and only count &lt;code&gt;booked&lt;/code&gt; rows in financial reports.&lt;/li&gt;
&lt;li&gt;Expire pendings older than N days instead of promoting them — card authorizations that get dropped never become booked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The classic migration bug: re-fetching overlapping windows after the switch, storing pending and booked as separate rows with auto-generated keys, and double-counting a month of groceries.&lt;/p&gt;

&lt;h3&gt;
  
  
  FITID mapping (QuickBooks / OFX users)
&lt;/h3&gt;

&lt;p&gt;If you push transactions into a &lt;code&gt;.qbo&lt;/code&gt;/OFX file or feed, the &lt;code&gt;&amp;lt;FITID&amp;gt;&lt;/code&gt; element is the dedupe key on the receiving side. Map it deterministically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Prefer the bank's transaction ID, sanitized (OFX hates angle brackets and control characters; mind length limits).&lt;/li&gt;
&lt;li&gt;If a bank truly gives no usable ID, hash &lt;code&gt;(amount, booking_date, remittance_info)&lt;/code&gt; — but identical coffee purchases on the same day will collide, so add a sequence salt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never generate FITIDs from a timestamp.&lt;/strong&gt; QuickBooks will happily import the same transaction every time, forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Endpoint mapping
&lt;/h2&gt;

&lt;p&gt;Nordigen's API shape &lt;a href="https://dev.to/johnfrandsen/open-banking-for-small-businesses-a-open-banking-for-small-businesses-a-practical-2026-1ldi"&gt;maps almost 1:1 onto a generic PSD2 AIS shape&lt;/a&gt;. The renaming: requisition → consent, institution → bank, end-user agreement → consent terms.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="c1"&gt;# Nordigen                          -&amp;gt;  Generic PSD2 AIS
# POST /token/new/                      POST /oauth/token
# GET  /institutions/                   GET  /banks
# POST /requisitions/                   POST /consents        (returns an auth URL)
# GET  /requisitions/{id}               GET  /consents/{id}
# GET  /accounts/{id}/balances/         GET  /accounts/{id}/balances
# GET  /accounts/{id}/transactions/     GET  /accounts/{id}/transactions
#      ?date_from=2026-01-01                 ?date_from=2026-01-01&amp;amp;bookingStatus=both
&lt;/span&gt;
&lt;span class="n"&gt;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.your-new-provider.example&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/oauth/token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grant_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_credentials&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;access_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;H&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# 1. create a consent (was: create a requisition)
&lt;/span&gt;&lt;span class="n"&gt;consent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/consents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bank_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SANDBOX_TERTIARY_BANK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redirect_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;REDIRECT_URL&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# 2. redirect the user to consent["auth_url"], handle the callback
# 3. list accounts and pull transactions with an overlap window
&lt;/span&gt;
&lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/accounts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;consent_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;consent&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;consent_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;txns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/accounts/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/transactions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date_from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-07-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bookingStatus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;both&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pull with an overlapping date window from your last successful sync — your dedupe keys absorb the overlap. For a simple app, this is one to two days of work, minus the gotchas above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Export all cached history from the old provider — before cancelling anything&lt;/li&gt;
&lt;li&gt;[ ] Pick a path: own eIDAS certs / own-keys aggregator / full broker&lt;/li&gt;
&lt;li&gt;[ ] Re-wire the auth flow; test consent creation + callback in sandbox first&lt;/li&gt;
&lt;li&gt;[ ] Add &lt;code&gt;(transaction_id, booking_date)&lt;/code&gt; dedupe with a booked/pending state column&lt;/li&gt;
&lt;li&gt;[ ] Add FITID mapping if anything downstream speaks OFX&lt;/li&gt;
&lt;li&gt;[ ] Migrate users in waves; each re-authorizes exactly once&lt;/li&gt;
&lt;li&gt;[ ] Calendar reminder at day ~85: your first 90-day re-consent wave&lt;/li&gt;
&lt;li&gt;[ ] Then cancel the old account&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One disclosure, so you know who's writing this: I build open-banking.io, a certificate-free EU/UK PSD2 account-information API where you use your own bank-issued client keys (the aggregator-style license is handled for you) for about €3/month — one of the option-2 paths above, and new customers are welcome. Whatever you pick, the mechanics in this guide apply the same way.&lt;/p&gt;

</description>
      <category>psd2</category>
      <category>openbanking</category>
      <category>nordigen</category>
      <category>gocardless</category>
    </item>
    <item>
      <title>Building a Personal Finance App with Open Banking APIs in 2026 (No Certificates Required)</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Wed, 12 Aug 2026 23:09:17 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/building-a-personal-finance-app-with-open-banking-apis-in-2026-no-certificates-required-31fh</link>
      <guid>https://dev.to/johnfrandsen/building-a-personal-finance-app-with-open-banking-apis-in-2026-no-certificates-required-31fh</guid>
      <description>&lt;p&gt;Building a personal finance app used to mean begging users to paste a CSV export — or worse, to share their banking password with a screen-scraping service. Open Banking was supposed to fix that, and under PSD2 it largely has. Every regulated EU/UK bank now exposes a standard API that returns your balances and transactions as clean JSON, with your explicit consent.&lt;/p&gt;

&lt;p&gt;So why isn't every indie developer shipping a net-worth dashboard on top of it?&lt;/p&gt;

&lt;p&gt;Because the "official" path to those APIs requires an &lt;strong&gt;eIDAS qualified certificate&lt;/strong&gt; (QWAC + QSeal), a regulator licence, and weeks of compliance paperwork. That's the wall most tutorials quietly skip. This one doesn't. We'll cover how PSD2 APIs actually work, why the certificate requirement blocks indie builders, and then build a working personal-finance tracker end-to-end using a certificate-free provider — with real curl and Python you can run today.&lt;/p&gt;

&lt;h2&gt;
  
  
  What an "Open Banking API" actually means
&lt;/h2&gt;

&lt;p&gt;Two pieces of jargon do most of the heavy lifting, and clearing them up front will save you hours:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PSD2&lt;/strong&gt; is the EU directive (second Payment Services Directive) that forced banks to open up customer data to licensed third parties, on the customer's request. The UK's &lt;strong&gt;Open Banking&lt;/strong&gt; standard is the local implementation of the same idea.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AISP / PIS&lt;/strong&gt; — an &lt;strong&gt;Account Information Service&lt;/strong&gt; reads data (balances, transactions); a &lt;strong&gt;Payment Initiation Service&lt;/strong&gt; can move money. For a personal finance app you only ever need &lt;strong&gt;AIS&lt;/strong&gt;. That's read-only by law.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The thing that makes Open Banking safe — and the thing that makes it annoying to set up — is the &lt;strong&gt;consent redirect flow&lt;/strong&gt;. Your app never sees the user's bank password. Instead:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Your app asks the API provider to start a connection for, say, "Nordea" or "Lunar".&lt;/li&gt;
&lt;li&gt;The provider redirects the user to their &lt;strong&gt;own bank's&lt;/strong&gt; login page (the bank's domain, the bank's TLS).&lt;/li&gt;
&lt;li&gt;The user logs in there and approves a scoped consent (e.g. "read balances and transactions for 90 days").&lt;/li&gt;
&lt;li&gt;The bank redirects back to your app with an authorisation code.&lt;/li&gt;
&lt;li&gt;Your app exchanges that code for access/refresh tokens and can now poll the data API.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;At no point do you touch credentials. The bank hands you a token with a defined scope and an expiry.&lt;/p&gt;

&lt;h2&gt;
  
  
  The certificate wall (and why it blocks indie builders)
&lt;/h2&gt;

&lt;p&gt;Here's the part the vendor docs bury. To call a bank's PSD2 API &lt;strong&gt;directly&lt;/strong&gt;, as your own licensed third-party provider (TPP), you must present a &lt;strong&gt;QWAC&lt;/strong&gt; (Qualified Website Authentication Certificate) and a &lt;strong&gt;QSeal&lt;/strong&gt; (Qualified Electronic Seal) — collectively the &lt;strong&gt;eIDAS qualified certificates&lt;/strong&gt;. You get those by:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Becoming an authorised AISP with your national regulator (the FCA in the UK, Finanstilsynet in Denmark, BaFin in Germany, etc.).&lt;/li&gt;
&lt;li&gt;Passing audits and maintaining a compliance programme.&lt;/li&gt;
&lt;li&gt;Buying the certificates from a qualified trust service provider — typically &lt;strong&gt;€1,000–€6,000/year&lt;/strong&gt;, plus setup.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a bank or a funded fintech, that's Tuesday. For an indie developer who just wants to build a net-worth dashboard over the weekend, it's a non-starter. So either you give up, or you use an &lt;strong&gt;aggregator&lt;/strong&gt; that is already licensed and lets you ride on its authorisation — handing you a simple API key instead of a certificate.&lt;/p&gt;

&lt;p&gt;That second option is the pragmatic one, and it's what the rest of this tutorial uses.&lt;/p&gt;

&lt;h2&gt;
  
  
  The certificate-free approach
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/johnfrandsen/comparing-european-open-banking-api-providers-in-2026-plaid-truelayer-tink-gocardless-125c"&gt;A handful of providers are already licensed AISPs&lt;/a&gt; and expose their aggregated API to you behind a plain &lt;strong&gt;API key&lt;/strong&gt;. They hold the certificates; you hold a key. You still go through the bank's consent redirect — the security model is identical — but you skip the licensing and certificate procurement entirely.&lt;/p&gt;

&lt;p&gt;The provider used in the code below is &lt;strong&gt;&lt;a href="https://open-banking.io/en/developers" rel="noopener noreferrer"&gt;open-banking.io&lt;/a&gt;&lt;/strong&gt; (full disclosure: it's a service I built, which is exactly why I can show you the cheapest path). It's certificate-free, costs €3/month for one account, and connects to EU/UK banks. The pattern — API key, consent flow, JSON data — is the same one you'll see at Yapily, Tink, TrueLayer, or Enable Banking; the difference is the absence of certificates and the price tag. Adapt the code to whichever provider you pick.&lt;/p&gt;

&lt;p&gt;Now let's build something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 0 — Get API access (no certificates, ~2 minutes)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Create an account and connect a real bank through the consent redirect described above.&lt;/li&gt;
&lt;li&gt;Generate an &lt;strong&gt;API key&lt;/strong&gt; and export a &lt;code&gt;credentials.json&lt;/code&gt; bundle. The bundle pairs your API key with your private decryption key (more on why in the security section).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is no eIDAS step. There is no audit. You authenticate every request with a single header:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Api-Key: &lt;/span&gt;&lt;span class="nv"&gt;$OBIO_API_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://open-banking.io/api/accounts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire auth model. No OAuth client-secret dance on your side, no mTLS, no certificate rotation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — List accounts and balances (Python)
&lt;/h2&gt;

&lt;p&gt;Install the Python client (or just use &lt;code&gt;httpx&lt;/code&gt; against the raw REST endpoint — your call):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;open-banking-io
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then list every connected account with its booked balance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;open_banking_io&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenBankingClient&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;OpenBankingClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_credentials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;credentials.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_accounts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;booked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;balances&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ITBD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;display_name&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;owner_name&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iban&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;): &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;booked&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it and you get something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Drift (DK64 6466…4927): 12482.55 DKK
Salary (DK21 3000…8841): 828.13 DKK
Buffer (DK90 5301…2207): 34010.00 DKK
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth noting. First, balances come in types (&lt;code&gt;ITBD&lt;/code&gt; = interim booked, &lt;code&gt;CLBD&lt;/code&gt; = closing booked, &lt;code&gt;XPCD&lt;/code&gt; = expected, etc.) — always pick the one that matches your use case. "Booked" is the safe default for a net-worth figure because it excludes pending authorisations. Second, &lt;code&gt;amount&lt;/code&gt; is a &lt;code&gt;decimal.Decimal&lt;/code&gt;, not a float — keep it that way or you'll silently lose pennies to floating-point error. This trips up a lot of finance apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Pull and paginate transactions
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;load_txns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;page_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;txns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_transactions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;page_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;txns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;page_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;page_size&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;txns&lt;/span&gt;

&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;OpenBankingClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_credentials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;credentials.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;all_txns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;acct&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_accounts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;all_txns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;load_txns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;acct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Loaded &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;all_txns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; transactions from &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;acct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;display_name&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;acct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;iban&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The PSD2 transaction schema (Berlin Group NextGenPSD2) is verbose by design: a single payment carries &lt;code&gt;creditorName&lt;/code&gt;, &lt;code&gt;debtorName&lt;/code&gt;, &lt;code&gt;remittanceInformationUnstructured&lt;/code&gt;, &lt;code&gt;transactionAmount&lt;/code&gt;, &lt;code&gt;bookingDate&lt;/code&gt;, &lt;code&gt;valueDate&lt;/code&gt;, and more. Normalise it once into your own flat shape and never look at the raw schema again:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;counterparty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;creditor_name&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;debtor_name&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;booking_date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;counterparty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;counterparty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;# Decimal; money in is positive, out is negative
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3 — Turn raw transactions into insights
&lt;/h2&gt;

&lt;p&gt;This is where your app actually adds value. The API gives you rows; your job is insight. A few primitives get you most of the way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;defaultdict&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;decimal&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;

&lt;span class="n"&gt;income&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spend&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;by_counterparty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;all_txns&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;by_counterparty&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;counterparty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;income&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;spend&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;amount&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Income:       &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;income&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Spending:     &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;spend&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Net cashflow: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;income&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;spend&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Top 5 spenders:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;amt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by_counterparty&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;kv&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])[:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;amt&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,.&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a real, working spend tracker. From here the usual next steps are obvious: bucket by month, flag recurring subscriptions (same counterparty, same amount, ~30-day cadence), raise an alert when income drops, and project a 30-day balance forecast. None of that needs the bank API at all — you already have clean, signed, normalised data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Schedule a daily sync and cache
&lt;/h2&gt;

&lt;p&gt;You don't want to hit the bank on every page load. The standard pattern is a daily server-side job that pulls fresh transactions into your own database; your frontend reads from your DB, not the bank.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# cron / scheduled job — run once per day
&lt;/span&gt;&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;OpenBankingClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_credentials&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;credentials.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sync_all&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;           &lt;span class="c1"&gt;# refresh every connected account from the bank
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;acct&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_accounts&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;load_txns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;acct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="nf"&gt;upsert_into_db&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# idempotent insert keyed on the transaction id
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sync_all()&lt;/code&gt; triggers an online refresh; &lt;code&gt;get_transactions()&lt;/code&gt; then returns the latest rows. Keep the credentials bundle server-side only (see below) and you have a textbook ETL: bank → provider → your DB → your app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security: zero-knowledge, and why it matters for a finance app
&lt;/h2&gt;

&lt;p&gt;A personal finance app is a high-trust product. Two security properties are worth understanding before you ship:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The consent layer is bank-enforced.&lt;/strong&gt; Read-only is not a setting in your code — it's a legal scope the bank grants at consent time. Your token simply cannot initiate a payment if you only asked for AIS. That's a much stronger guarantee than a "we promise not to move money" checkbox.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;End-to-end encryption / zero-knowledge.&lt;/strong&gt; The better providers don't store your data in a form they can read. open-banking.io, for example, encrypts every account and transaction envelope under a key derived from &lt;em&gt;your&lt;/em&gt; private key (ECDH P‑256 → HKDF‑SHA256 → AES‑256‑GCM). The server holds ciphertext it cannot decrypt; decryption happens in your client process, using the private key from your &lt;code&gt;credentials.json&lt;/code&gt;. If the provider is breached, the attacker gets sealed blobs.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Practical implication: &lt;strong&gt;the &lt;code&gt;credentials.json&lt;/code&gt; bundle contains your private decryption key. Never ship it to a browser, a mobile app, or a public repo.&lt;/strong&gt; Keep it on your server — ideally as a Kubernetes Secret or a secrets manager, mounted read-only:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create secret generic obio-credentials &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--from-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;credentials.json&lt;span class="o"&gt;=&lt;/span&gt;./open-banking.io-credentials-2026-08-12.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A word on cost
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/johnfrandsen/the-cheapest-open-banking-apis-for-small-businesses-and-indie-builders-in-2026-5cab"&gt;Most aggregators price for enterprise&lt;/a&gt;: per-connection fees in the euros, minimum commits, sales calls. That's fine if you're a funded startup, brutal if you're an indie. The certificate-free providers sit at the cheap end of the spectrum — open-banking.io is €3/month for one account and €1/month per additional account, no minimum. When you pick a provider, model your cost per active user, because transaction volume and connection count are where the bill climbs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you've got
&lt;/h2&gt;

&lt;p&gt;At this point you have:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A way to read bank data &lt;strong&gt;without an eIDAS certificate or a licence&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A consent flow that's bank-enforced and read-only by law.&lt;/li&gt;
&lt;li&gt;Clean, normalised transactions to build any insight on top of.&lt;/li&gt;
&lt;li&gt;A daily-sync ETL pattern with zero-knowledge encryption.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the complete foundation of a personal finance app — net worth, cashflow, subscription tracking, the lot — built on PSD2 APIs without touching the certificate wall. The hard part of fintech isn't the data plumbing any more; it's the product you build on top of clean data. Go build something worth using.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I'm the developer behind open-banking.io, which is why I can point you at the cheapest, certificate-free path. The architecture in this post works with any PSD2 aggregator — swap the SDK and API key for Yapily, Tink, TrueLayer, or Enable Banking and the same steps hold.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openbanking</category>
      <category>fintech</category>
      <category>python</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The Best Plaid Alternative in Europe (2025): A Developer's Guide to PSD2 APIs</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Tue, 11 Aug 2026 13:11:53 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/the-best-plaid-alternative-in-europe-2025-a-developers-guide-to-psd2-apis-ocp</link>
      <guid>https://dev.to/johnfrandsen/the-best-plaid-alternative-in-europe-2025-a-developers-guide-to-psd2-apis-ocp</guid>
      <description>&lt;p&gt;If you've tried to build a European fintech app on top of Plaid, you've probably hit the same wall as everyone else: &lt;strong&gt;Plaid's European (UK/EU) coverage is thin, and it isn't really a PSD2-native product.&lt;/strong&gt; That sends a lot of developers searching for a &lt;em&gt;Plaid alternative in Europe&lt;/em&gt; — one that speaks the EU's Open Banking protocol natively, covers the right banks, and ideally doesn't bury you in compliance paperwork.&lt;/p&gt;

&lt;p&gt;This article is a practical, code-first comparison of the main options for accessing bank account data (balances, transactions, account details) across the UK and EU. I'll show you what each provider actually gives you as a developer, what the eIDAS certificate situation is (this matters more than you'd think), and working curl, Python, and JavaScript examples you can run today.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclosure up front:&lt;/strong&gt; I'm involved with &lt;a href="https://open-banking.io" rel="noopener noreferrer"&gt;open-banking.io&lt;/a&gt;, a certificate-free PSD2 API that's one of the options in this comparison. I'll mark that clearly when it comes up, but the code patterns and the comparison below apply to whichever provider you choose.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Why developers look for a Plaid alternative in Europe
&lt;/h2&gt;

&lt;p&gt;Plaid is excellent in the US. In Europe it's a different story. A few concrete reasons European teams move off (or never start with) Plaid:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bank coverage gaps.&lt;/strong&gt; Plaid's European coverage relies on integrations that are often thinner than local PSD2 aggregators. Many mid-tier and regional banks in Germany, France, Spain, and the Nordics are missing or unreliable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not PSD2-first.&lt;/strong&gt; Plaid's API shape was designed for the US banking model. PSD2's &lt;a href="https://dev.to/johnfrandsen/open-banking-for-small-businesses-a-open-banking-for-small-businesses-a-practical-2026-1ldi"&gt;Account Information Service (AIS)&lt;/a&gt; flow — with strong-customer-authentication (SCA) redirects, consent, and 90-day re-auth — fits awkwardly on top.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing.&lt;/strong&gt; Plaid's per-call / success-based pricing can get expensive for use cases that poll balances or sync transactions frequently (accounting, expense, lending underwriting).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Licensing friction.&lt;/strong&gt; Pure TPP (Third Party Provider) aggregators that operate under their own eIDAS certificate are the native model in the EU. Plaid doesn't sit cleanly in that category.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The good news: under &lt;strong&gt;PSD2 (Payment Services Directive 2)&lt;/strong&gt;, every bank in the EEA and the UK is &lt;em&gt;legally required&lt;/em&gt; to expose a free, standardized API for account information. That means there's a healthy market of PSD2-native aggregators, and you have real choices.&lt;/p&gt;




&lt;h2&gt;
  
  
  What "PSD2-native" actually means (and why the eIDAS certificate matters)
&lt;/h2&gt;

&lt;p&gt;Before the comparison, one concept that trips up a lot of teams:&lt;/p&gt;

&lt;p&gt;To call a bank's PSD2 API directly, a third party is supposed to hold an &lt;strong&gt;eIDAS QWAC (Qualified Website Authentication Certificate)&lt;/strong&gt; — an expensive, audit-heavy certificate issued by a Qualified Trust Service Provider. Banks use it to verify the caller is a licensed TPP.&lt;/p&gt;

&lt;p&gt;In practice, this means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DIY path:&lt;/strong&gt; You register as a TPP with your national regulator, go through audits, buy a QWAC (roughly EUR 1,000-6,000/year), and integrate each bank's API individually. Months of work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aggregator path:&lt;/strong&gt; You use a provider (Yapily, Tink, Enable Banking, etc.) that &lt;em&gt;already holds&lt;/em&gt; the eIDAS certificate and has done the per-bank integrations. You just call &lt;em&gt;their&lt;/em&gt; API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Almost all aggregators require you to either bring your own eIDAS certificate or operate under theirs with KYB/onboarding overhead. &lt;strong&gt;One notable exception is open-banking.io, which is built specifically to remove the certificate requirement for AIS use cases&lt;/strong&gt; — more on that in the comparison.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Requirement&lt;/th&gt;
&lt;th&gt;DIY PSD2&lt;/th&gt;
&lt;th&gt;Typical aggregator&lt;/th&gt;
&lt;th&gt;open-banking.io&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;eIDAS QWAC certificate&lt;/td&gt;
&lt;td&gt;Required (you buy it)&lt;/td&gt;
&lt;td&gt;They hold it; you do KYB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Not required&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-bank integration&lt;/td&gt;
&lt;td&gt;You build each one&lt;/td&gt;
&lt;td&gt;Done for you&lt;/td&gt;
&lt;td&gt;Done for you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TPP license&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;td&gt;They hold it&lt;/td&gt;
&lt;td&gt;Not required for AIS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to first API call&lt;/td&gt;
&lt;td&gt;Weeks-months&lt;/td&gt;
&lt;td&gt;Days (after onboarding)&lt;/td&gt;
&lt;td&gt;Minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The main Plaid alternatives in Europe: compared
&lt;/h2&gt;

&lt;p&gt;Here's an honest, developer-eye-view comparison. I'll cover each in more detail below.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Coverage&lt;/th&gt;
&lt;th&gt;Auth model&lt;/th&gt;
&lt;th&gt;Requires eIDAS cert?&lt;/th&gt;
&lt;th&gt;Pricing model&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Yapily&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;UK + EU (strong)&lt;/td&gt;
&lt;td&gt;Yapily-hosted consent&lt;/td&gt;
&lt;td&gt;Yes (KYB onboarding)&lt;/td&gt;
&lt;td&gt;Per successful AIS call&lt;/td&gt;
&lt;td&gt;Enterprise, lending&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tink (Visa)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Pan-European&lt;/td&gt;
&lt;td&gt;Tink-hosted / app-2-app&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Volume tiers, enterprise&lt;/td&gt;
&lt;td&gt;Large fintechs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Enable Banking&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Nordics + EU&lt;/td&gt;
&lt;td&gt;Redirect/decoupled&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Per-call&lt;/td&gt;
&lt;td&gt;Nordic-focused apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Nordigen (GoCardless)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;EU + UK (free tier)&lt;/td&gt;
&lt;td&gt;Redirect&lt;/td&gt;
&lt;td&gt;Free tier has limits&lt;/td&gt;
&lt;td&gt;Freemium then paid&lt;/td&gt;
&lt;td&gt;MVPs, small apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;open-banking.io&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;EU + UK&lt;/td&gt;
&lt;td&gt;Certificate-free HTTP&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Simple tiers&lt;/td&gt;
&lt;td&gt;SMB tools, accountants, fast prototyping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Plaid (EU/UK)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Partial EU/UK&lt;/td&gt;
&lt;td&gt;Plaid-link&lt;/td&gt;
&lt;td&gt;n/a (US-centric)&lt;/td&gt;
&lt;td&gt;Per-call/success&lt;/td&gt;
&lt;td&gt;US-first products&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  A note on each
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Yapily&lt;/strong&gt; is probably the most frequently cited Plaid alternative in Europe for a reason: solid coverage, a clean REST API, and a real TPP license. The trade-off is enterprise-grade onboarding (KYB, contract) and per-call pricing that adds up at scale.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tink&lt;/strong&gt; (now owned by Visa) has excellent coverage and the nicest developer UX in the enterprise tier. The cost and procurement process reflect that positioning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enable Banking&lt;/strong&gt; is the go-to if you're focused on the Nordics; they have deep coverage there and a straightforward API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nordigen / GoCardless&lt;/strong&gt; offers a genuinely free tier, which makes it attractive for MVPs. The free tier has bank and call limits, and you graduate to paid pricing as you grow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;open-banking.io&lt;/strong&gt; is the one I'm closest to. Its wedge is being &lt;strong&gt;certificate-free&lt;/strong&gt;: you can start calling AIS endpoints without buying a QWAC or going through TPP-style onboarding. That makes it useful for SMB tools, accounting integrations, and prototyping where you don't want compliance to be the long pole.&lt;/p&gt;




&lt;h2&gt;
  
  
  The flow every European open banking API shares
&lt;/h2&gt;

&lt;p&gt;Whichever provider you pick, the end-to-end flow for reading a user's bank data looks roughly like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Create a consent / session&lt;/strong&gt; — you tell the provider which bank and what scopes (accounts, balances, transactions).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirect the user to SCA&lt;/strong&gt; — under PSD2, the user authenticates at their bank (redirect or decoupled/app-to-app). This satisfies Strong Customer Authentication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exchange the auth code&lt;/strong&gt; for an access token (or the provider gives you a session/requisition ID).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fetch data&lt;/strong&gt; — accounts, balances, transactions — over the next ~90 days until re-consent is required.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let's see that in code.&lt;/p&gt;




&lt;h2&gt;
  
  
  Code examples
&lt;/h2&gt;

&lt;p&gt;The examples below use open-banking.io's API shape (HTTP/JSON, bearer token) because it's what I can show end-to-end here. The &lt;em&gt;structure&lt;/em&gt; is nearly identical for Yapily, Tink, and others — only the endpoint names and auth details differ.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Initialize a bank session (consent)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Create a session for a German bank account (read-only AIS)&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.open-banking.io/v1/sessions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$OBI_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "bank": "DEUTSCHE_BANK",
    "country": "DE",
    "scopes": ["accounts", "balances", "transactions"],
    "redirect_url": "https://yourapp.example.com/callback"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Response (abbreviated):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"session_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sess_01HZX..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"awaiting_authentication"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"auth_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://auth.open-banking.io/authorize?session=sess_01HZX..."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You redirect your user to &lt;code&gt;auth_url&lt;/code&gt;. They authenticate with their bank (SCA), and you're called back at &lt;code&gt;redirect_url&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. List accounts
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OBI_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;session_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sess_01HZX...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;accounts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.open-banking.io/v1/sessions/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/accounts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;acct&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;acct&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;iban&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;acct&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;acct&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;currency&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Fetch balances and transactions
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Node 18+ (built-in fetch)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OBI_TOKEN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sessionId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sess_01HZX...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// Balances&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;balances&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`https://api.open-banking.io/v1/sessions/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/accounts/acc_123/balances`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Available balance:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;balances&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;balanceAmount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Transactions (last 30 days)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;txns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`https://api.open-banking.io/v1/sessions/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sessionId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/accounts/acc_123/transactions?date_from=2025-07-11`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;txns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bookingDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transactionAmount&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remittanceInformationUnstructured&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. A complete Python helper
&lt;/h3&gt;

&lt;p&gt;Here's a small reusable client you can adapt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OpenBankingClient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.open-banking.io/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OBI_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Session&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;redirect_url&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/sessions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bank&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bank&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scopes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accounts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;balances&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transactions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redirect_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;redirect_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/sessions/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/accounts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transactions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;since&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utcnow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/sessions/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/accounts/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/transactions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;date_from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="c1"&gt;# Usage
&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenBankingClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REVOLT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://app.example.com/cb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# ... user completes SCA at session["auth_url"] ...
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;acct&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;session_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
    &lt;span class="n"&gt;txns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transactions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;session_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;acct&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;acct&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;iban&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;txns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; transactions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Tip:&lt;/strong&gt; Always treat the &lt;code&gt;auth_url&lt;/code&gt; redirect as the SSA/SCA boundary. Don't try to cache or replay user bank credentials — under PSD2 you must never see them. That's the whole point of the redirect flow.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  How to choose: a decision guide
&lt;/h2&gt;

&lt;p&gt;If you're not sure which Plaid alternative in Europe fits your project, use this rough guide:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You're an enterprise / regulated fintech doing lending at scale&lt;/strong&gt; → Yapily or Tink. Budget for onboarding and per-call pricing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're Nordic-focused&lt;/strong&gt; → Enable Banking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're building an MVP and want free to start&lt;/strong&gt; → Nordigen's free tier, then re-evaluate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're building SMB tooling, an accountant integration, or a prototype — and you want to skip the eIDAS certificate and TPP onboarding entirely&lt;/strong&gt; → open-banking.io.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're US-first and only need light EU coverage&lt;/strong&gt; → Plaid may still be the pragmatic choice.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The single biggest differentiator to interrogate during evaluation is the &lt;strong&gt;certificate/onboarding requirement&lt;/strong&gt;. If a one-week procurement delay kills your project, that narrows the field considerably.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pitfalls to avoid when switching from Plaid
&lt;/h2&gt;

&lt;p&gt;A few things that bite people moving off Plaid onto a PSD2-native provider:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;90-day re-authentication.&lt;/strong&gt; PSD2 requires re-consent every 90 days for AIS. Plaid abstracts this differently than PSD2 aggregators do; plan your UX around re-auth reminders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transaction field variance.&lt;/strong&gt; The Berlin Group NextGen PSD2 schema (used across much of the EU) has rich but unevenly-populated fields. &lt;code&gt;remittanceInformationUnstructured&lt;/code&gt; is where payer names/reference often live — but not every bank fills it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sandbox vs. production coverage gaps.&lt;/strong&gt; A bank present in a provider's sandbox may behave differently in production. Test on real (small) accounts early.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limits per bank.&lt;/strong&gt; Some banks rate-limit TPP calls aggressively. Cache responses where it makes sense and respect &lt;code&gt;Retry-After&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Currency and timezone on transactions.&lt;/strong&gt; Always read both &lt;code&gt;transactionAmount&lt;/code&gt; (with currency) and &lt;code&gt;bookingDate&lt;/code&gt; vs. &lt;code&gt;valueDate&lt;/code&gt; — they differ and matter for reconciliation.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Official references
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PSD2 Directive:&lt;/strong&gt; &lt;a href="https://eur-lex.europa.eu/legal-content/EN/TXT/?uri=celex%3A32015L2366" rel="noopener noreferrer"&gt;Directive (EU) 2015/2366&lt;/a&gt; — the foundational directive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RTS on SCA and CSC:&lt;/strong&gt; Commission Delegated Regulation (EU) 2018/389 — defines strong customer authentication and the 90-day AIS re-consent rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Berlin Group NextGen PSD2:&lt;/strong&gt; the Open API standard most EU banks implement — &lt;a href="https://www.berlin-group.org/" rel="noopener noreferrer"&gt;berlin-group-ngp&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UK Open Banking Standard:&lt;/strong&gt; &lt;a href="https://www.openbanking.org.uk/" rel="noopener noreferrer"&gt;openbanking.org.uk&lt;/a&gt; — the UK's reference implementation and API specs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EBA Guidelines on PSD2:&lt;/strong&gt; the European Banking Authority's technical guidance on API access.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;A good &lt;strong&gt;Plaid alternative in Europe&lt;/strong&gt; is one that treats PSD2 as a first-class citizen: native SCA flows, real coverage of EEA + UK banks, and a licensing model that doesn't drown you in paperwork. Yapily, Tink, Enable Banking, and Nordigen are all legitimate picks depending on your scale and region. And if your use case is SMB tooling, accounting integration, or fast prototyping where the eIDAS certificate requirement is the bottleneck, &lt;a href="https://open-banking.io" rel="noopener noreferrer"&gt;open-banking.io&lt;/a&gt; is purpose-built to remove exactly that friction.&lt;/p&gt;

&lt;p&gt;Whichever you pick, the underlying PSD2 guarantees are the same: standardized access to account information, balances, and transactions across the entire EU and UK — no scraping, no credentials sharing, fully consented.&lt;/p&gt;

&lt;p&gt;If you found this useful, I write about open banking and fintech engineering here on dev.to. Questions or war stories from your own integration? Drop them in the comments.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article was written by &lt;a href="https://dev.to/johnfrandsen"&gt;johnfrandsen&lt;/a&gt;. I'm involved with open-banking.io, which is mentioned in the comparison above. The technical content and code patterns are applicable across providers.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>banking</category>
      <category>webdev</category>
      <category>fintech</category>
      <category>openbanking</category>
    </item>
    <item>
      <title>PSD2 Consent Expiry vs Token Refresh: Why Your Bank Connection Stops After 90 Days</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Thu, 06 Aug 2026 15:18:33 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/psd2-consent-expiry-vs-token-refresh-why-your-bank-connection-stops-after-90-days-31el</link>
      <guid>https://dev.to/johnfrandsen/psd2-consent-expiry-vs-token-refresh-why-your-bank-connection-stops-after-90-days-31el</guid>
      <description>&lt;p&gt;You refresh your OAuth tokens on schedule. Your token endpoint returns 200s. Your job is green. And yet — at almost exactly 90 days, a chunk of your users' bank connections silently stop pulling transactions. The error rate ticks up at the same interval for everyone, regardless of when they last logged in. Token refresh is healthy, so what expired?&lt;/p&gt;

&lt;p&gt;This is the single most common operational mystery in open-banking integrations, and the reason it's confusing is that &lt;strong&gt;PSD2 runs three independent timers, and most developers only reason about one of them.&lt;/strong&gt; If you've only ever thought about OAuth access-token lifetime, you'll find that 90-day cliff inexplicable — because that one timer never reaches 90 days.&lt;/p&gt;

&lt;p&gt;Let me untangle the three timers, the regulatory citations behind them, and how to tell them apart at runtime so you can recover correctly instead of blindly retrying.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three timers at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Timer&lt;/th&gt;
&lt;th&gt;Set by&lt;/th&gt;
&lt;th&gt;Typical duration&lt;/th&gt;
&lt;th&gt;Recovery when it lapses&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;OAuth access token&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;ASPSP token endpoint&lt;/td&gt;
&lt;td&gt;seconds–hours&lt;/td&gt;
&lt;td&gt;Silent &lt;code&gt;refresh_token&lt;/code&gt; exchange, no user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Consent &lt;code&gt;validUntil&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PSU (capped by bank, often 90d)&lt;/td&gt;
&lt;td&gt;30–180 days&lt;/td&gt;
&lt;td&gt;Re-grant consent; silent if SCA still fresh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;SCA re-authentication window&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PSD2 RTS, hard cap &lt;strong&gt;≤180 days&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;≤180 days&lt;/td&gt;
&lt;td&gt;Redirect PSU to bank for strong authentication&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first one is OAuth as you know it. The other two are PSD2-specific and they are the timers that actually explain your 90-day problem. The trick is that &lt;strong&gt;token refresh cannot extend timer 2 or 3.&lt;/strong&gt; A refresh gets you a new bearer token for a consent that &lt;em&gt;itself&lt;/em&gt; is expiring, or for an SCA session that has gone stale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timer 1 — The access token (the one you already know)
&lt;/h2&gt;

&lt;p&gt;When the PSU finishes the redirect flow, your ASPSP hands you a Bearer &lt;code&gt;access_token&lt;/code&gt; plus, usually, a &lt;code&gt;refresh_token&lt;/code&gt;. The access token is what you put in the &lt;code&gt;Authorization: Bearer …&lt;/code&gt; header on &lt;code&gt;/accounts&lt;/code&gt;, &lt;code&gt;/balances&lt;/code&gt;, &lt;code&gt;/transactions&lt;/code&gt;. Per the Berlin Group NextGenPSD2 Implementation Guidelines, this token's lifetime is implementation-defined and typically short — minutes to a couple of hours — because it's bound to a single consent and a single SCA session.&lt;/p&gt;

&lt;p&gt;Refreshing it is the easy part:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;refresh_access_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refresh_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ASPSP_BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/as/token.oauth2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;grant_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refresh_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refresh_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;refresh_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CLIENT_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CLIENT_SECRET&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# or private_key_jwt
&lt;/span&gt;        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Content-Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/x-www-form-urlencoded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# {"access_token": ..., "expires_in": 600, "refresh_token": ...?}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to internalize:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;refresh_token&lt;/code&gt; rotation is common.&lt;/strong&gt; Many ASPSPs issue a new refresh token on every refresh and invalidate the old one. If you replay an old refresh token you'll get a 400. Persist the new one &lt;em&gt;before&lt;/em&gt; you consider the refresh successful.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh only ever gives you more access tokens for the same consent.&lt;/strong&gt; It does not extend the consent's &lt;code&gt;validUntil&lt;/code&gt;, and it does not reset the SCA clock. This is why a healthy refresh job doesn't prevent the 90-day cliff.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Timer 2 — Consent &lt;code&gt;validUntil&lt;/code&gt; (this is your 90-day problem)
&lt;/h2&gt;

&lt;p&gt;A &lt;em&gt;consent&lt;/em&gt; in PSD2 is the PSU's authorization for you (the AISP) to &lt;a href="https://dev.to/johnfrandsen/open-banking-for-small-businesses-a-open-banking-for-small-businesses-a-practical-2026-1ldi"&gt;read their account data&lt;/a&gt;. It is a first-class object on the bank's side, identified by &lt;code&gt;consentId&lt;/code&gt;, with a lifecycle (&lt;code&gt;received&lt;/code&gt; → &lt;code&gt;valid&lt;/code&gt; → &lt;code&gt;expired&lt;/code&gt; | &lt;code&gt;revokedByPsu&lt;/code&gt; | &lt;code&gt;terminatedByTpp&lt;/code&gt;). Every consent carries a &lt;code&gt;validUntil&lt;/code&gt; date — the day after which the bank will refuse all access even if your token is technically valid.&lt;/p&gt;

&lt;p&gt;This is the timer that defaults to 90 days at most ASPSPs, and it's the one that produces your synchronized error spike. Where does 90 days come from? Two places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;PSU control.&lt;/strong&gt; Under PSD2 Article 66, the PSU sets (or accepts a default for) the consent's validity period. Many banks simply default &lt;code&gt;validUntil&lt;/code&gt; to 90 days from creation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;National transposition and scheme defaults.&lt;/strong&gt; UK Open Banking's original CMA9 requirements centred on 90-day re-authentication. German BaFin-influenced implementations, Polish, and several Nordic banks also default to 90. Some go as low as 30.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The PSD2 RTS itself does &lt;strong&gt;not&lt;/strong&gt; mandate 90 days. The RTS ceiling is 180 days (see Timer 3). The 90-day value is a pervasive &lt;em&gt;default&lt;/em&gt;, not a legal maximum — but because so many banks ship that default, 90 days is the number you'll see in production.&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;validUntil&lt;/code&gt; passes, you get an explicit, distinguishable failure. NextGenPSD2 ASPSPs return it as a &lt;code&gt;403&lt;/code&gt; with &lt;code&gt;application/problem+json&lt;/code&gt; (RFC 7807) and a &lt;code&gt;tppMessages&lt;/code&gt; array containing &lt;code&gt;CONSENT_EXPIRED&lt;/code&gt;. The consent resource's status flips to &lt;code&gt;expired&lt;/code&gt;. &lt;strong&gt;A &lt;code&gt;refresh_token&lt;/code&gt; grant will still succeed against the token endpoint, and you will still get a brand-new access token&lt;/strong&gt; — but every AIS call with that token returns 403. This is the exact trap: "my token refresh works, but every data call fails."&lt;/p&gt;

&lt;p&gt;The recovery is &lt;strong&gt;re-grant the consent&lt;/strong&gt;, which — critically — may or may not require the user to re-authenticate, depending on Timer 3.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timer 3 — SCA re-authentication (the hard wall PSD2 imposes)
&lt;/h2&gt;

&lt;p&gt;Strong Customer Authentication (SCA) is the act of the PSU proving their identity to their bank (two-factor: something they know + have + are). PSD2's Regulatory Technical Standards — Commission Delegated Regulation (EU) 2018/389, &lt;strong&gt;Article 10&lt;/strong&gt; — set the maximum interval between two SCAs for account-information access at &lt;strong&gt;180 days&lt;/strong&gt;. In plain terms: an ASPSP is permitted to &lt;em&gt;not&lt;/em&gt; re-challenge the PSU with SCA for up to 180 days after the last SCA, and after 180 days a fresh SCA is mandatory.&lt;/p&gt;

&lt;p&gt;This is the one timer that is legally fixed, bank-independent, and &lt;strong&gt;uncrossable by any background process.&lt;/strong&gt; No amount of token refreshing or consent re-granting lets you cross 180 days without the PSU doing strong authentication again.&lt;/p&gt;

&lt;p&gt;Here's the subtle part that ties it to Timer 2: a consent can be technically &lt;code&gt;valid&lt;/code&gt; (not past &lt;code&gt;validUntil&lt;/code&gt;, not revoked) while its SCA is stale. So you can hit &lt;em&gt;two different&lt;/em&gt; failures against the same consent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Consent past &lt;code&gt;validUntil&lt;/code&gt; → &lt;strong&gt;CONSENT_EXPIRED&lt;/strong&gt; (re-grant; silent if SCA fresh)&lt;/li&gt;
&lt;li&gt;SCA older than 180 days → the bank forces an SCA on the next consent grant → &lt;strong&gt;you must redirect the PSU&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The interaction is what trips people up. Many banks deliberately set &lt;code&gt;validUntil&lt;/code&gt; = 90 days so that consent renewal always happens &lt;em&gt;well inside&lt;/em&gt; the 180-day SCA window, which lets renewal be a silent one-tap re-approval rather than a full redirect. That's the UX-friendly interpretation of "90 days." But if you configured a longer &lt;code&gt;validUntil&lt;/code&gt; (e.g., 170 days to minimise user friction), the renewal will collide with the SCA window and &lt;em&gt;force&lt;/em&gt; a redirect + 2FA on the PSU. You traded frequency for friction and lost on both.&lt;/p&gt;

&lt;p&gt;A reasonable rule of thumb: &lt;strong&gt;treat 90 days as the renewal cadence and 180 days as the cliff you must never reach.&lt;/strong&gt; Renew at day ~80, and you'll always be inside the SCA window, which lets renewal be silent or one-tap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the signals: which timer just fired?
&lt;/h2&gt;

&lt;p&gt;The cheapest debugging win is mapping the HTTP response to the responsible timer, because each has a different recovery path. ASPSPs conforming to NextGenPSD2 return errors as RFC 7807 problem documents:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="k"&gt;HTTP&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="m"&gt;1.1&lt;/span&gt; &lt;span class="m"&gt;403&lt;/span&gt; &lt;span class="ne"&gt;Forbidden&lt;/span&gt;
&lt;span class="na"&gt;Content-Type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;application/problem+json&lt;/span&gt;

&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://example.com/errors/forbidden"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Consent expired"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tppMessages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ERROR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CONSENT_EXPIRED"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The consent used is expired."&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;HTTP status&lt;/th&gt;
&lt;th&gt;&lt;code&gt;tppMessages.code&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Timer fired&lt;/th&gt;
&lt;th&gt;Recovery&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;401&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;TOKEN_INVALID&lt;/code&gt; / &lt;code&gt;TOKEN_EXPIRED&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1 — access token&lt;/td&gt;
&lt;td&gt;Refresh silently&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CONSENT_EXPIRED&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;2 — &lt;code&gt;validUntil&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Re-grant consent (silent if SCA fresh)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;401/403&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;CONSENT_INVALID&lt;/code&gt; / &lt;code&gt;CONSENT_STATUS_INVALID&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;2 — revoked/terminated&lt;/td&gt;
&lt;td&gt;Re-grant; expect SCA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CONSENT_UNKNOWN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;bad &lt;code&gt;consentId&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Re-grant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;401 (on consent grant)&lt;/td&gt;
&lt;td&gt;SCA required&lt;/td&gt;
&lt;td&gt;3 — SCA &amp;gt;180d&lt;/td&gt;
&lt;td&gt;Redirect PSU to bank&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The status code alone is not enough — a 401 from a token-endpoint failure means something different from a 401 on an AIS endpoint. Always parse &lt;code&gt;tppMessages.code&lt;/code&gt;; that's the contract NextGenPSD2 gives you to disambiguate.&lt;/p&gt;

&lt;h2&gt;
  
  
  A recovery orchestrator
&lt;/h2&gt;

&lt;p&gt;Here's the pattern I use to keep this sane: one wrapper that classifies the failure and routes to the correct recovery, instead of scattering retry logic across call sites.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Recovery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;RETRY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retry&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;                 &lt;span class="c1"&gt;# transient; just call again
&lt;/span&gt;    &lt;span class="n"&gt;REFRESH_TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;refresh&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;       &lt;span class="c1"&gt;# timer 1
&lt;/span&gt;    &lt;span class="n"&gt;REGRANT_CONSENT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;regrant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;     &lt;span class="c1"&gt;# timer 2
&lt;/span&gt;    &lt;span class="n"&gt;USER_ACTION_SCA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user_sca&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;    &lt;span class="c1"&gt;# timer 3
&lt;/span&gt;
&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ConsentState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;consent_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;valid_until&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;              &lt;span class="c1"&gt;# epoch
&lt;/span&gt;    &lt;span class="n"&gt;last_sca_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;              &lt;span class="c1"&gt;# epoch
&lt;/span&gt;    &lt;span class="n"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;access_token_exp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;         &lt;span class="c1"&gt;# epoch
&lt;/span&gt;    &lt;span class="n"&gt;refresh_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ConsentState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Recovery&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;codes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tppMessages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;codes&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TOKEN_INVALID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TOKEN_EXPIRED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Recovery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REFRESH_TOKEN&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;codes&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CONSENT_EXPIRED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}:&lt;/span&gt;
        &lt;span class="c1"&gt;# consent's validUntil has passed; re-grant.
&lt;/span&gt;        &lt;span class="c1"&gt;# Whether the PSU must do SCA depends on timer 3:
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_sca_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;170&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Recovery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;USER_ACTION_SCA&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Recovery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REGRANT_CONSENT&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;codes&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CONSENT_INVALID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CONSENT_UNKNOWN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CONSENT_STATUS_INVALID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Recovery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;USER_ACTION_SCA&lt;/span&gt;     &lt;span class="c1"&gt;# revoked or corrupted; full re-grant safest
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;Recovery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RETRY&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unhandled: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;codes&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key design decision: &lt;strong&gt;&lt;code&gt;CONSENT_EXPIRED&lt;/code&gt; does not unconditionally mean "redirect the user."&lt;/strong&gt; It only means the user must re-authenticate if SCA is also stale (timer 3). If SCA is fresh, re-granting the consent can be a server-to-server call that returns a new &lt;code&gt;consentId&lt;/code&gt; without ever bouncing the PSU to their bank. That's the difference between a 0.1% drop-off and a 20% drop-off on renewal day.&lt;/p&gt;

&lt;p&gt;The companion function does the refresh side, so the call site stays clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call_ais&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ConsentState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;access_token_exp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;_refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                        &lt;span class="c1"&gt;# timer 1, proactive
&lt;/span&gt;        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ASPSP_BASE&lt;/span&gt;&lt;span class="si"&gt;}{&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;access_token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                               &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Consent-ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;consent_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                      &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;match&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="n"&gt;Recovery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REFRESH_TOKEN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nf"&gt;_refresh&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="n"&gt;Recovery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RETRY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="n"&gt;Recovery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;REGRANT_CONSENT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nf"&gt;_regrant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
            &lt;span class="n"&gt;case&lt;/span&gt; &lt;span class="n"&gt;Recovery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;USER_ACTION_SCA&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;NeedsUserAction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;consent_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;NeedsUserAction&lt;/code&gt; is the signal to your higher layer to send a notification ("tap here to reconnect your bank") instead of looping forever in the background.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't wait for the user to hit the error
&lt;/h2&gt;

&lt;p&gt;The mistake that produces the synchronized 90-day cliff is &lt;strong&gt;reactive renewal.&lt;/strong&gt; If you only re-grant when a read call fails, then every PSU who consented in the same onboarding wave hits &lt;code&gt;CONSENT_EXPIRED&lt;/code&gt; within the same week, and they all get a reconnect prompt at once — usually in a batch that overwhelms whatever SCA capacity the bank has, and always at a worse UX moment than the original onboarding.&lt;/p&gt;

&lt;p&gt;The fix is a scheduled sweeper that renews each consent ~10 days &lt;em&gt;before&lt;/em&gt; whichever timer is closer to lapsed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sweep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;due_soon_days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;consent_store&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;d_to_consent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;valid_until&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;d_to_sca&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;last_sca_at&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;180&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d_to_consent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_to_sca&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;due_soon_days&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;d_to_sca&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;notify_user&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reconnect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# timer 3: needs SCA, user must act
&lt;/span&gt;            &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;_regrant&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                     &lt;span class="c1"&gt;# timer 2: silent renewal
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now renewals spread out across the calendar because they're driven by each PSU's individual consent timestamp, and the SCA-bound ones get queued for the user instead of crashing a read call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why banks vary so much
&lt;/h2&gt;

&lt;p&gt;A short, non-exhaustive map of the defaults you'll actually hit:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Region / scheme&lt;/th&gt;
&lt;th&gt;Typical &lt;code&gt;validUntil&lt;/code&gt; default&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UK (CMA9, OBIE)&lt;/td&gt;
&lt;td&gt;90 days&lt;/td&gt;
&lt;td&gt;Historical re-auth cadence; FCA aligned SCA ceiling to RTS 180d&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Germany (Berlin Group)&lt;/td&gt;
&lt;td&gt;90 days&lt;/td&gt;
&lt;td&gt;Common; some Sparkassen allow up to 180&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nordics&lt;/td&gt;
&lt;td&gt;90–180 days&lt;/td&gt;
&lt;td&gt;Decoupled flows common&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Poland (Polish API)&lt;/td&gt;
&lt;td&gt;up to 180 days&lt;/td&gt;
&lt;td&gt;Often closer to the RTS ceiling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;France (STET)&lt;/td&gt;
&lt;td&gt;90–180 days&lt;/td&gt;
&lt;td&gt;Bank-dependent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Always read the specific ASPSP's documentation for both &lt;code&gt;validUntil&lt;/code&gt; policy &lt;em&gt;and&lt;/em&gt; whether refresh-token rotation is enabled. They differ per bank even within the same scheme, and they're the two settings that break naive integrations.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistakes I see most often
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Treating 401 and 403 as "token expired."&lt;/strong&gt; A &lt;code&gt;CONSENT_EXPIRED&lt;/code&gt; 403 is not a token problem; refreshing won't help and will waste your rate budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-granting on every 401.&lt;/strong&gt; Some teams panic and kick the user back through SCA for any failure. You burn the 180-day SCA goodwill and annoy users. Classify first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Renewing reactively.&lt;/strong&gt; All consents from a launch cohort lapse together. Sweep proactively.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming refresh tokens are long-lived.&lt;/strong&gt; Rotation invalidates them. Persist the new token in the same transaction as the refresh.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Setting &lt;code&gt;validUntil&lt;/code&gt; too long to "reduce friction."&lt;/strong&gt; Past ~150 days you collide with SCA and force a redirect anyway — at a worse time. 90-day renewal inside a fresh SCA window is the local optimum.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Three timers, one OAuth mental model will not save you.&lt;/strong&gt; Access token (short, silent), consent &lt;code&gt;validUntil&lt;/code&gt; (≈90d, re-grant), SCA (≤180d, RTS Art. 10, must involve the PSU).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token refresh extends timer 1 only.&lt;/strong&gt; It never extends timers 2 or 3, which is the whole reason the 90-day cliff exists despite healthy refresh jobs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classify failures by &lt;code&gt;tppMessages.code&lt;/code&gt;, not by status code alone.&lt;/strong&gt; &lt;code&gt;CONSENT_EXPIRED&lt;/code&gt;, &lt;code&gt;TOKEN_EXPIRED&lt;/code&gt;, and a forced SCA each demand a different recovery path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sweep proactively.&lt;/strong&gt; Renew ~10 days before the nearest timer lapses, route SCA-bound renewals to the user, and let the rest happen silently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Get the classification and the sweeper right and the 90-day cliff stops being a mystery outage and becomes a quiet, spread-out background job — which is exactly what open banking should feel like when it's working.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I maintain open-banking.io, a self-hosted PSD2/open banking platform.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>bankingopenbanking</category>
      <category>psd2</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Plaid Alternatives in Europe: A PSD2 Developer's Comparison for 2026</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Mon, 03 Aug 2026 01:11:37 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/plaid-alternatives-in-europe-a-psd2-developers-comparison-for-2026-3gak</link>
      <guid>https://dev.to/johnfrandsen/plaid-alternatives-in-europe-a-psd2-developers-comparison-for-2026-3gak</guid>
      <description>&lt;h1&gt;
  
  
  Plaid Alternatives in Europe: A PSD2 Developer's Comparison for 2026
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Cover image suggestion:&lt;/strong&gt; A stylised map of Europe made of interconnected bank/API nodes, with API request/response cards floating over it and a small "no certificate" badge in the corner. Muted fintech palette (deep navy + teal accents). Caption: "European open banking APIs compared."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Plaid is a great product — if you're building for the US. But once your customers are in the Eurozone, the UK, or the Nordics, you start hitting questions Plaid wasn't designed to answer: &lt;em&gt;Where is my data hosted? Do I need an eIDAS certificate to go live? Why am I paying per-connection fees on top of PSD2 banks I could reach for free?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I build and maintain &lt;a href="https://open-banking.io" rel="noopener noreferrer"&gt;open-banking.io&lt;/a&gt;, so I spend an unhealthy amount of time comparing PSD2 providers. This is the comparison I wish existed when I started — focused on what actually matters to a developer shipping a small-business finance tool in Europe: &lt;strong&gt;pricing, certificate requirements, coverage, and API ergonomics.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why look beyond Plaid in Europe?
&lt;/h2&gt;

&lt;p&gt;Three reasons come up again and again in my inbox:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Data residency.&lt;/strong&gt; Many EU customers (and their lawyers) want bank data to stay in the EEA. Plaid's core infrastructure is US-based, which adds SCC/DPA paperwork.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PSD2 was designed for free access.&lt;/strong&gt; European regulators intended account information (AIS) and payment initiation (PIS) to be reachable by licensed Third-Party Providers. A pure PSD2 aggregator can often reach the same banks at a fraction of Plaid's per-connection cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The eIDAS tax.&lt;/strong&gt; To call regulated PSD2 bank APIs directly, you need a QWAC (TLS) and often a QSeal (signing) certificate — &lt;strong&gt;€1,000–€6,000+ per year&lt;/strong&gt; before you make a single API call. Some European providers let you avoid this entirely (more below).&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  TL;DR comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;eIDAS cert needed?&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Pricing (illustrative)&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Enable Banking&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (they hold it; you don't)&lt;/td&gt;
&lt;td&gt;Full-service TPP&lt;/td&gt;
&lt;td&gt;Usage-based, free sandbox&lt;/td&gt;
&lt;td&gt;Broad Nordic/EU coverage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Nordigen / GoCardless&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No (free AIS via PSD2)&lt;/td&gt;
&lt;td&gt;Freemium AIS&lt;/td&gt;
&lt;td&gt;Free 90-day lookback; Premium ~€300+/mo&lt;/td&gt;
&lt;td&gt;Free historical data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Yapily&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (they hold it)&lt;/td&gt;
&lt;td&gt;Headless API-only&lt;/td&gt;
&lt;td&gt;Per-call, free tier&lt;/td&gt;
&lt;td&gt;White-label, no UI needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TrueLayer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (they hold it)&lt;/td&gt;
&lt;td&gt;Full-stack + payments&lt;/td&gt;
&lt;td&gt;From ~€500/mo + per-connection&lt;/td&gt;
&lt;td&gt;VRP / variable recurring payments&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Tink&lt;/strong&gt; (Visa)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Enterprise platform&lt;/td&gt;
&lt;td&gt;On request (enterprise budgets)&lt;/td&gt;
&lt;td&gt;Large orgs, Visa ecosystem&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Plaid (EU)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (they hold it)&lt;/td&gt;
&lt;td&gt;Full-stack&lt;/td&gt;
&lt;td&gt;Per-item, dev free → paid&lt;/td&gt;
&lt;td&gt;US-first, EU secondary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;open-banking.io&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No — cert-free&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Unified + fallback&lt;/td&gt;
&lt;td&gt;Free / simple tiers&lt;/td&gt;
&lt;td&gt;Indie &amp;amp; SMB builders&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;Pricing changes constantly and most of these providers only quote on a call. Treat the numbers above as &lt;em&gt;order of magnitude&lt;/em&gt; and confirm on their pricing pages.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The PSD2 landscape: AIS, PIS, and certificates
&lt;/h2&gt;

&lt;p&gt;Under PSD2, banks must expose two regulated services:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AIS (Account Information Services)&lt;/strong&gt; — read balances and transactions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PIS (Payment Initiation Services)&lt;/strong&gt; — initiate a payment from the user's account.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To access the &lt;em&gt;regulated&lt;/em&gt; API endpoints, a TPP must present a &lt;strong&gt;QWAC&lt;/strong&gt; certificate (for the TLS handshake) and usually a &lt;strong&gt;QSeal&lt;/strong&gt; certificate (to sign the request payload). You buy these from a Qualified Trust Service Provider (QTSP) — DigiCert, CERTUM, SwissSign, Itana, and friends.&lt;/p&gt;

&lt;p&gt;Here's the catch for small builders: the certificate costs more than your first year of API calls. That's the entire reason &lt;strong&gt;cert-free aggregators&lt;/strong&gt; exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The eIDAS problem (and how to dodge it)
&lt;/h2&gt;

&lt;p&gt;If you're an indie developer or a small business, you have two realistic paths:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use a provider that already holds the certificates.&lt;/strong&gt; Enable Banking, Yapily, TrueLayer, Tink, and Plaid all do. You never touch eIDAS; they proxy the regulated calls. You pay for that convenience in usage fees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a cert-free connector.&lt;/strong&gt; Nordigen offers free AIS on PSD2 banks without you needing a certificate, and &lt;a href="https://open-banking.io" rel="noopener noreferrer"&gt;open-banking.io&lt;/a&gt; (which I maintain) was built specifically so you can reach your bank data &lt;strong&gt;with zero certificates and zero QTSP paperwork.&lt;/strong&gt; It unifies multiple underlying aggregators and falls back gracefully, so a single API call works whether the bank is regulated-PSD2 or needs a direct connector.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a deeper dive on the certificate-free path, I wrote a separate practical guide — &lt;a href="https://dev.to/johnfrandsen/open-banking-without-an-eidas-certificate-a-practical-developers-guide-2026-1j6"&gt;Open Banking Without an eIDAS Certificate&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provider notes (the nuance the table can't capture)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Enable Banking&lt;/strong&gt; — Finnish, quietly excellent Nordic + EU coverage. Their docs are clean and they're the reigning #1 for a lot of "PSD2 account information API" queries. Downside: no famous free tier.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nordigen (now GoCardless)&lt;/strong&gt; — The disruptor. They made &lt;em&gt;free&lt;/em&gt; 90-day transaction history a thing. If you just need a cash-flow view for an SMB, this is hard to beat. GoCardless acquisition means payments are now bundled under the "Bank Pay" brand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Yapily&lt;/strong&gt; — Pure headless. No UI widgets at all — you build everything. Great for white-label; more work if you wanted a drop-in consent screen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TrueLayer&lt;/strong&gt; — Strong on payments, especially VRP (Variable Recurring Payments), which is the future of recurring subscriptions and account-to-account in the UK/EU. Expect per-connection fees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tink&lt;/strong&gt; — Acquired by Visa. Enterprise-grade, enterprise-priced. Brilliant data enrichment; overkill for a side project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plaid EU&lt;/strong&gt; — Solid coverage, the best DX if you're already on Plaid in the US. But you inherit the per-item model and the US-centric data posture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;open-banking.io&lt;/strong&gt; — Mine. The pitch is simple: one API, no eIDAS, no QTSP, works across regulated and direct connectors. Built for people who want to ship an SMB finance tool this week, not file compliance paperwork for three months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code: the same task, three ways
&lt;/h2&gt;

&lt;p&gt;Fetching balances and recent transactions is the canonical "hello world" of open banking. Here it is against a cert-free endpoint versus a classic Plaid call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;curl — cert-free (open-banking.io style):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# After a one-time user consent flow, you get an access token.&lt;/span&gt;
&lt;span class="c"&gt;# No certificate, no QTSP, no mTLS handshake to configure.&lt;/span&gt;
curl https://api.open-banking.io/v1/accounts/transactions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$ACCESS_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Account-Id: &lt;/span&gt;&lt;span class="nv"&gt;$ACCOUNT_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Python — same thing, with error handling:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_transactions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.open-banking.io/v1/accounts/transactions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;access_token&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Account-Id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transactions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;txns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fetch_transactions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;acct_123&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ACCESS_TOKEN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;txns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; transactions pulled — no certificate required.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For contrast, a &lt;strong&gt;classic Plaid EU&lt;/strong&gt; call requires a &lt;code&gt;client_id&lt;/code&gt; + &lt;code&gt;secret&lt;/code&gt;, an &lt;code&gt;access_token&lt;/code&gt; from their Link flow, and — behind the scenes — Plaid holds the eIDAS certs and bills you per item:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;plaid&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;plaid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;PlaidClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PLAID_CLIENT_ID&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PLAID_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;environment&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;plaid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Environment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Production&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;transactions_get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;access_token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;start_date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-01-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;end_date&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-08-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same outcome, very different cost and compliance posture.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose (a 60-second framework)
&lt;/h2&gt;

&lt;p&gt;Ask yourself three questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Do you ever need PIS (payments), or just AIS (data)?&lt;/strong&gt; If only data, Nordigen or open-banking.io will save you money. If payments, TrueLayer and Yapily lead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is data residency non-negotiable?&lt;/strong&gt; Then rule out US-first providers and lean into EU-headquartered aggregators.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Can your budget absorb €2k+/yr in certificate + compliance before launch?&lt;/strong&gt; If no — and for most SMB builders the answer is no — a cert-free connector is the pragmatic default.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  A note on affiliation
&lt;/h2&gt;

&lt;p&gt;I maintain &lt;a href="https://open-banking.io" rel="noopener noreferrer"&gt;open-banking.io&lt;/a&gt;, so I'm plainly not neutral — I built it because I needed a cert-free path myself. The comparison above is my honest read of the market, and every provider listed is a legitimate choice depending on your constraints. Run the numbers for your own volume before committing.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; If you're a small business or indie builder in Europe and your goal is &lt;em&gt;account information without the eIDAS tax&lt;/em&gt;, start with a cert-free connector (Nordigen for free history, open-banking.io for a unified cert-free path). If you need payments at scale, look at TrueLayer and Yapily. Plaid remains the safe default only if you're already invested in it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What did I get wrong? Which provider should I add a real benchmark for next? I'd love to hear what you're building in the comments.&lt;/em&gt;&lt;/p&gt;

&lt;h1&gt;
  
  
  fintech #api #banking #psd2
&lt;/h1&gt;

</description>
      <category>fintech</category>
      <category>api</category>
      <category>banking</category>
      <category>psd2</category>
    </item>
    <item>
      <title>Build an MCP server that lets Claude read EU bank data (no eIDAS certificate needed)</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Fri, 31 Jul 2026 15:29:10 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/build-an-mcp-server-that-lets-claude-read-eu-bank-data-no-eidas-certificate-needed-55dn</link>
      <guid>https://dev.to/johnfrandsen/build-an-mcp-server-that-lets-claude-read-eu-bank-data-no-eidas-certificate-needed-55dn</guid>
      <description>&lt;p&gt;If you've tried to give an LLM agent access to your &lt;em&gt;own&lt;/em&gt; bank account, you've probably hit the same wall every time: PSD2 says banks must expose account information APIs, but actually calling one usually requires an &lt;strong&gt;eIDAS QWAC certificate&lt;/strong&gt; — a €500–700/year artefact gated behind KYC, a qualified trust service provider, and a CA that treats developers like compliance risks. Agents can read your GitHub, your calendar, your email… and then bounce off the bank.&lt;/p&gt;

&lt;p&gt;I maintain open-banking.io, and I wrote previously about &lt;em&gt;why&lt;/em&gt; agents can't reach banks. This post is the constructive follow-up: &lt;strong&gt;how to actually do it&lt;/strong&gt;, by wiring a cert-free open-banking API into an MCP server that Claude (or any MCP-aware client) can call as a tool. Disclosure up front — I'm the maintainer, so judge the bias accordingly. The pattern works with any redirect-based AIS provider; I'm just using the one I know best.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why MCP is the right shape for this
&lt;/h2&gt;

&lt;p&gt;Model Context Protocol tools are a good fit for bank data for three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Read-only by design.&lt;/strong&gt; AIS (Account Information Services) is inherently a read operation. Mapping &lt;code&gt;get_balances&lt;/code&gt; / &lt;code&gt;get_transactions&lt;/code&gt; to MCP tools makes the blast radius obvious in a way that a raw HTTP client does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consent is a distinct step.&lt;/strong&gt; Open banking uses an OAuth-style redirect flow where the &lt;em&gt;human&lt;/em&gt; logs into their bank and approves scopes. MCP's tool-call boundary matches this: the agent never sees credentials, it only sees the result of an approved session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composability.&lt;/strong&gt; Once balances and transactions are tools, the agent can reason across them ("sum last 30 days of grocery spend") without you writing a single analytic endpoint.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The catch is that you still need &lt;em&gt;someone&lt;/em&gt; to hold the eIDAS cert and run the consent redirect. That's the wedge &lt;a href="https://dev.to/johnfrandsen/open-banking-without-an-eidas-certificate-a-practical-developers-guide-2026-1j6"&gt;cert-free aggregators&lt;/a&gt; occupy: they hold the certificate and expose a plain REST API to you, so your MCP server never touches eIDAS at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌──────────┐   MCP (stdio/HTTP)   ┌─────────────────┐   REST + OAuth2   ┌────────────────┐
│  Claude  │ ───────────────────► │  your MCP server │ ───────────────► │ cert-free AIS  │
│  Desktop │ ◄─────────────────── │  (FastMCP, Py)   │ ◄─────────────── │  (open-banking │
└──────────┘   tool results        └─────────────────┘   JSON            │     .io)       │
                                                                        └───────┬────────┘
                                                                                │ holds eIDAS QWAC
                                                                         ┌──────▼──────┐
                                                                         │  EU bank    │
                                                                         │  PSD2 API   │
                                                                         └─────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bank still sees a PSD2-regulated call with a valid QWAC. Your agent sees a JSON object. The certificate "tax" is paid once, upstream, by the aggregator — not per developer.&lt;/p&gt;

&lt;h2&gt;
  
  
  A minimal MCP server
&lt;/h2&gt;

&lt;p&gt;Here's a working FastMCP server in ~60 lines. It exposes three tools: list supported banks, start a consent flow, and read data once consent is granted.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# eu_bank_mcp.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;webbrowser&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.fastmcp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastMCP&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="n"&gt;API_BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OBI_API_BASE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c1"&gt;# e.g. https://api.open-banking.io
&lt;/span&gt;&lt;span class="n"&gt;API_KEY&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OBI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;SESSION_TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;  &lt;span class="c1"&gt;# cached after consent; in prod, persist to a secrets store
&lt;/span&gt;
&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastMCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eu-bank&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_headers&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API_KEY&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;SESSION_TOKEN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-Consent-Token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SESSION_TOKEN&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;list_banks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DK&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;List banks available for connection in a given ISO country code.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API_BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/v1/banks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;country&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_headers&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;banks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;connect_bank&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bank_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Start the redirect consent flow for a bank. Opens the user&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s browser.
    Returns the consent URL so the agent can surface it to the human.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;SESSION_TOKEN&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API_BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/v1/consent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bank_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;bank_id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_headers&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;consent_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;consent_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;SESSION_TOKEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pending_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;webbrowser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;consent_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# the HUMAN authenticates here, not the agent
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Open this URL in a browser and approve: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;consent_url&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_balances&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return balances for all connected accounts. Requires prior consent.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API_BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/v1/balances&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_headers&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;balances&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_transactions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return transactions for an account over the last N days.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API_BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/v1/transactions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;account_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;days&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_headers&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;transactions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# stdio transport by default
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what's &lt;em&gt;not&lt;/em&gt; here: no private key, no CSR, no certificate renewal cron, no &lt;code&gt;openssl&lt;/code&gt; incantations. The eIDAS burden is a problem the aggregator already solved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Point Claude Desktop at it
&lt;/h2&gt;

&lt;p&gt;Drop this into &lt;code&gt;claude_desktop_config.json&lt;/code&gt; (macOS: &lt;code&gt;~/Library/Application Support/Claude/&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"eu-bank"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"python"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"/absolute/path/to/eu_bank_mcp.py"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"OBI_API_BASE"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.open-banking.io"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"OBI_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sk_live_your_key"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Restart Claude, and the tools appear in the function list. A natural conversation now works:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;You:&lt;/strong&gt; Connect my Danish bank, then tell me how much I spent on groceries last month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude:&lt;/strong&gt; I'll call &lt;code&gt;list_banks("DK")&lt;/code&gt;… found &lt;code&gt;nordfyns-bank&lt;/code&gt;. Calling &lt;code&gt;connect_bank&lt;/code&gt; — please approve in the browser that just opened. Once you're back, I'll pull balances and 30 days of transactions and categorize them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The redirect flow is the key safety property: &lt;strong&gt;the agent never sees the bank login.&lt;/strong&gt; It only receives a consent token after a human completes authentication at the bank's own domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cert-based vs cert-free MCP access
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Direct PSD2 API (you hold eIDAS)&lt;/th&gt;
&lt;th&gt;Cert-free aggregator + MCP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Upfront cost&lt;/td&gt;
&lt;td&gt;€500–700/yr + KYC&lt;/td&gt;
&lt;td&gt;~€3/mo per bank, no KYC on you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to first call&lt;/td&gt;
&lt;td&gt;2–8 weeks (CA issuance)&lt;/td&gt;
&lt;td&gt;minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Certificate ops&lt;/td&gt;
&lt;td&gt;renewals, key rotation, HSM&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What the agent sees&lt;/td&gt;
&lt;td&gt;raw SCA + signed calls&lt;/td&gt;
&lt;td&gt;clean REST → MCP tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compliance surface&lt;/td&gt;
&lt;td&gt;you're the regulated-ish party&lt;/td&gt;
&lt;td&gt;aggregator is regulated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;large regulated TPPs&lt;/td&gt;
&lt;td&gt;indie builders, agents, personal finance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For an agent that just needs to &lt;em&gt;read&lt;/em&gt; balances and transactions to answer questions, the right column wins almost every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security notes worth stating plainly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scope to AIS only.&lt;/strong&gt; Never wire PIS (payment initiation) into an agent tool casually. Read access is recoverable; initiated payments are not. If you need payment tools, gate them behind a human-in-the-loop confirmation step, not silent agent autonomy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache the consent token securely.&lt;/strong&gt; The sketch above uses a global for clarity; in production, persist it to the OS keychain or a secrets manager. Treat it like a session cookie — it &lt;em&gt;is&lt;/em&gt; one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consent expires.&lt;/strong&gt; EU PSD2 consents max out at 90 days by default and many banks cap at 30. Your server should detect a 401 on read calls and re-trigger &lt;code&gt;connect_bank&lt;/code&gt; rather than silently failing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log the agent's tool calls.&lt;/strong&gt; The first time Claude calls &lt;code&gt;get_transactions&lt;/code&gt; on its own initiative, you'll want an audit trail. FastMCP emits per-call events; ship them somewhere you control.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this unlocks
&lt;/h2&gt;

&lt;p&gt;Once bank data is just tool calls, the things agents can do get interesting fast: recurring-transfer detection ("you have three subscriptions you haven't used in 60 days"), cash-flow forecasting from transaction history, cross-account net-worth summaries, and — the one I'm most excited about — letting a personal-finance agent reconcile against a budgeting tool without screen-scraping or CSV exports.&lt;/p&gt;

&lt;p&gt;The barrier was never the AI. It was the certificate. Removing it from the agent's path is what makes bank-aware agents practical today.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I maintain open-banking.io, the cert-free aggregation API used in the examples above. The MCP pattern works with any redirect-based AIS provider. Docs and a free tier to try the consent flow end-to-end are at &lt;a href="https://open-banking.io" rel="noopener noreferrer"&gt;open-banking.io&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openbanking</category>
    </item>
    <item>
      <title>European Alternatives to Plaid: Open Banking APIs Compared</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Thu, 30 Jul 2026 15:15:11 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/european-alternatives-to-plaid-open-banking-apis-compared-4off</link>
      <guid>https://dev.to/johnfrandsen/european-alternatives-to-plaid-open-banking-apis-compared-4off</guid>
      <description>&lt;h2&gt;
  
  
  Why European Developers Need Plaid Alternatives
&lt;/h2&gt;

&lt;p&gt;Plaid dominates open banking in the US, but European developers face a different landscape. GDPR data sovereignty rules, PSD2 regulatory requirements, and — most importantly — the eIDAS certificate wall create a gap that Plaid's EU offering doesn't fully bridge.&lt;/p&gt;

&lt;p&gt;If you're building a fintech app, a budgeting tool, or an accounting integration in Europe, you need an open banking API that actually works within the EU regulatory framework without requiring you to become a licensed financial institution just to read transaction data.&lt;/p&gt;

&lt;p&gt;Let's compare what's actually available.&lt;/p&gt;

&lt;h2&gt;
  
  
  The eIDAS Certificate Problem
&lt;/h2&gt;

&lt;p&gt;Before comparing providers, you need to understand the single biggest barrier in European open banking: the &lt;strong&gt;eIDAS Qualified Website Authentication Certificate (QWAC)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Under PSD2, to directly access bank APIs as an Account Information Service Provider (AISP), you need:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Regulatory approval&lt;/strong&gt; from your national financial authority&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An eIDAS QWAC certificate&lt;/strong&gt; (€500–€3,000/year depending on the CA)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An eIDAS Qualified Electronic Seal (QSeal)&lt;/strong&gt; for signing API requests&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ongoing compliance audits&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a solo developer or small startup, this is a non-starter. The certificate alone &lt;a href="https://dev.to/johnfrandsen/open-banking-without-an-eidas-certificate-a-practical-developers-guide-2026-1j6"&gt;costs more than many side projects earn in a year&lt;/a&gt;. This is why most developers use an &lt;strong&gt;aggregator&lt;/strong&gt; — a licensed provider that handles the regulatory burden and exposes a simpler API.&lt;/p&gt;

&lt;h2&gt;
  
  
  European Open Banking APIs Compared
&lt;/h2&gt;

&lt;p&gt;Here's how the major European open banking API providers stack up for developers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Certificate Required?&lt;/th&gt;
&lt;th&gt;Pricing Model&lt;/th&gt;
&lt;th&gt;Bank Coverage&lt;/th&gt;
&lt;th&gt;Developer Experience&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Plaid (EU)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (via Plaid)&lt;/td&gt;
&lt;td&gt;Per-user, scales fast&lt;/td&gt;
&lt;td&gt;Good EU coverage&lt;/td&gt;
&lt;td&gt;Excellent docs, mature SDKs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TrueLayer&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (via TrueLayer)&lt;/td&gt;
&lt;td&gt;Volume-based tiers&lt;/td&gt;
&lt;td&gt;Strong UK/EU&lt;/td&gt;
&lt;td&gt;Good docs, GraphQL + REST&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Tink (Visa)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (via Tink)&lt;/td&gt;
&lt;td&gt;Enterprise-tier pricing&lt;/td&gt;
&lt;td&gt;Strong Nordics/EU&lt;/td&gt;
&lt;td&gt;Solid API, enterprise focus&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Yapily&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (via Yapily)&lt;/td&gt;
&lt;td&gt;Per-call pricing&lt;/td&gt;
&lt;td&gt;Broad EU coverage&lt;/td&gt;
&lt;td&gt;REST API, good coverage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;GoCardless (Nordigen)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (via GoCardless)&lt;/td&gt;
&lt;td&gt;Free tier (limited) + paid&lt;/td&gt;
&lt;td&gt;Good EU coverage&lt;/td&gt;
&lt;td&gt;Changing pricing model&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Salt Edge&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Yes (via Salt Edge)&lt;/td&gt;
&lt;td&gt;Custom enterprise&lt;/td&gt;
&lt;td&gt;Global coverage&lt;/td&gt;
&lt;td&gt;Mature API, broad reach&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  The Pattern
&lt;/h3&gt;

&lt;p&gt;Notice the pattern? Every major aggregator requires a certificate — &lt;em&gt;theirs&lt;/em&gt;. You don't need your own eIDAS cert, but you pay for it through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-user pricing&lt;/strong&gt; (Plaid charges per connected account)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Volume tiers&lt;/strong&gt; (TrueLayer, Yapily gatekeep features behind volume)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise contracts&lt;/strong&gt; (Tink, Salt Edge often require sales calls)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Changing free tiers&lt;/strong&gt; (GoCardless/Nordigen has repeatedly adjusted their free offering)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This creates a real problem: as your app grows, costs scale unpredictably, and the provider can change terms at any time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Indie Developers Actually Need
&lt;/h2&gt;

&lt;p&gt;After watching hundreds of open banking integrations across GitHub repos, forums, and community projects, here's what developers consistently ask for:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Predictable, low cost&lt;/strong&gt; — flat-rate pricing that doesn't scale per user&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No certificate overhead&lt;/strong&gt; — direct API access without eIDAS procurement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Standard REST API&lt;/strong&gt; — no proprietary SDKs required&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transparent bank coverage&lt;/strong&gt; — know exactly which banks are supported&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-serve signup&lt;/strong&gt; — no sales calls, no enterprise contracts&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The community response to this gap has been telling. In the last year alone, developers have built independent client libraries for the GoCardless/Nordigen API in &lt;strong&gt;Rust, .NET, Go, Python, and Dart&lt;/strong&gt; — volunteer effort aimed at making a cert-heavy endpoint more accessible. Two community-built open banking MCP servers have appeared for AI agents. The demand is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evaluating Your Options
&lt;/h2&gt;

&lt;p&gt;When choosing a European Plaid alternative, ask these questions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost predictability:&lt;/strong&gt; Can you model your costs at 10, 100, and 1,000 users? Per-user models break at scale. Look for flat-rate or usage-based pricing you can control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Certificate model:&lt;/strong&gt; Do you need your own eIDAS cert, or does the provider handle it? If you're self-hosting or building an internal tool, the certificate requirement may be your biggest cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bank coverage:&lt;/strong&gt; European bank coverage varies significantly. A provider strong in the Nordics may be weak in Southern Europe. Check the actual coverage list, not marketing claims.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exit strategy:&lt;/strong&gt; What happens if the provider changes pricing or shuts down? Can you migrate your integration, or are you locked in?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Developer experience:&lt;/strong&gt; Is there a sandbox you can test without signing up? Are the docs public? Can you make a test API call in under 5 minutes?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bottom Line
&lt;/h2&gt;

&lt;p&gt;The European open banking landscape is fragmented because PSD2 created a regulatory framework that favors large, well-capitalized aggregators. The certificate tax is real — it's the reason indie developers face higher costs and fewer options than their US counterparts.&lt;/p&gt;

&lt;p&gt;When evaluating alternatives, prioritize cost predictability, transparent coverage, and self-serve access. The provider that lets you start building in minutes without a sales call is the one that respects your time as a developer.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I maintain &lt;a href="https://open-banking.io" rel="noopener noreferrer"&gt;open-banking.io&lt;/a&gt;, a cert-free PSD2 open banking API service for indie developers. This article focuses on the broader ecosystem landscape — evaluate all options against your specific needs.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openbankingaeuropefintechpis</category>
    </item>
    <item>
      <title>Your AI Agent Can Call Every API — Except Your Bank. Here's Why.</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Tue, 28 Jul 2026 23:20:52 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/your-ai-agent-can-call-every-api-except-your-bank-heres-why-46gi</link>
      <guid>https://dev.to/johnfrandsen/your-ai-agent-can-call-every-api-except-your-bank-heres-why-46gi</guid>
      <description>&lt;p&gt;Every API you'd want an agent to call has a clean path: an API key, an OAuth dance, maybe a scoped token. Your LLM can read your calendar, file your issues, query your database. Then it hits your bank — and the door slams.&lt;/p&gt;

&lt;p&gt;It's not that bank APIs don't exist. PSD2 forced EU banks to publish them. It's that the &lt;strong&gt;authentication layer is built for institutions, not agents.&lt;/strong&gt; Getting onto a bank's API as a third party requires an eIDAS &lt;strong&gt;QWAC + eSEAL certificate&lt;/strong&gt; — a Qualified Website Authentication Certificate and a Qualified Electronic Seal, issued by a QTSP after a weeks-long onboarding that runs €2,000–€10,000 per year.&lt;/p&gt;

&lt;p&gt;No agent framework ships a tool that can produce a Qualified Seal. No indie dev spinning up an MCP server on a Friday night is going to file for one. So the agent wave, which is consuming every other API surface, &lt;strong&gt;bounces off banking entirely.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The aggregator vacuum, explained
&lt;/h2&gt;

&lt;p&gt;This is the actual reason the open-banking landscape is &lt;a href="https://dev.to/johnfrandsen/comparing-european-open-banking-api-providers-in-2026-plaid-truelayer-tink-gocardless-125c"&gt;dominated by aggregators&lt;/a&gt; — Yapily, TrueLayer, Tink, Plaid. It's not that they have better technology. It's that they're the only entities willing and able to &lt;a href="https://dev.to/johnfrandsen/open-banking-without-an-eidas-certificate-a-practical-developers-guide-2026-1j6"&gt;absorb the certificate tax&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;They buy the eIDAS certs. They handle the regulatory registration as Account Information Service Providers (AISPs) and Payment Initiation Service Providers (PISPs). They maintain the per-bank connector sprawl (every bank implements the Berlin Group NextGenPSD2 standard &lt;em&gt;slightly&lt;/em&gt; differently). Then they sell you a single REST key on top.&lt;/p&gt;

&lt;p&gt;It's a perfectly rational business. But it has a side effect: &lt;strong&gt;it makes bank data the only major API category where you cannot talk directly to the source without a six-figure compliance posture.&lt;/strong&gt; For agents, that's a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why MCP makes this acute
&lt;/h2&gt;

&lt;p&gt;Model Context Protocol (MCP) servers are how tools get into agent runtimes this year. The pattern is clean — a server exposes a handful of tools, an agent discovers and calls them. For most domains this is trivial: wrap a REST API, return JSON, done.&lt;/p&gt;

&lt;p&gt;For banking it breaks at the first step, because the wrapper needs a client certificate to even complete the TLS handshake with the bank. That's not a config value you paste in — it's a credential bound to a legal entity that has passed regulatory vetting. An MCP server you'd publish to a registry can't carry one. So today, the realistic "bank MCP server" is one that talks to an aggregator, not to banks.&lt;/p&gt;

&lt;p&gt;I think that's the wrong default. The interesting design question for agent-native banking is whether we can get &lt;strong&gt;read access to one's own account data without forcing the agent stack through an intermediary.&lt;/strong&gt; That requires a tier of access that doesn't exist in the PSD2 rulebook as written, but which several adjacent trends are pulling toward.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a certificate-free read tier would unlock
&lt;/h2&gt;

&lt;p&gt;PSD2 already distinguishes AIS (read) from PIS (write). The certificate burden exists for both, but the &lt;em&gt;risk&lt;/em&gt; profiles are wildly different: a consented, read-only view of your own balances and transactions is not the same as initiating a payment. Yet both sit behind the same eIDAS wall.&lt;/p&gt;

&lt;p&gt;A practical, agent-friendly pattern is starting to emerge at the edges:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sandbox-first, cert-free.&lt;/strong&gt; Several bank developer portals and aggregator sandboxes expose test data with only an API key. An MCP server can ship against these today, giving agents real-shaped data to develop against without any compliance posture. This is where most agent/banking experimentation will live for the next year.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AISP-lite / hosted-agent consent.&lt;/strong&gt; Instead of every agent operator registering as an AISP, an entity holds the AISP license and grants scoped, consent-bound read tokens to &lt;em&gt;agent processes&lt;/em&gt; — not to end companies. The agent runs against your consented accounts; it never holds the certificate. This is structurally what aggregators do, but with the boundary drawn so that the &lt;strong&gt;agent is the consumer, not the company.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Certificate-free production read tiers.&lt;/strong&gt; The genuinely useful (and genuinely hard) version: a bank-data API that gives consented read access with API-key auth, with the certificate burden absorbed by the API provider rather than pushed onto every integrator. This is what I've been building toward with open-banking.io — a certificate-free read tier so that the smallest integrator, or a single MCP server, can reach bank data without the eIDAS tax.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third option is the one that matters for agents. If accessing your own transaction history requires you to first become a regulated financial institution, agent-native personal finance is a non-starter.&lt;/p&gt;

&lt;h2&gt;
  
  
  A sketch of what the agent side looks like
&lt;/h2&gt;

&lt;p&gt;To make this concrete, here's the shape of an MCP tool that reads account data through a certificate-free tier — no client cert, no eIDAS, just a key and user consent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// An MCP "list_transactions" tool definition&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"list_transactions"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"List recent transactions for a consented bank account."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inputSchema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"account_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"since"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"date"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"limit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"integer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"default"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"account_id"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The server-side handler — note: no mTLS, no client certificate
&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;list_transactions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.example-bankdata.io/v1/accounts/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/transactions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;since&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;since&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;X-API-Key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OBI_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;normalize_for_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;  &lt;span class="c1"&gt;# flatten to agent-friendly JSON
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hard part isn't this code — it's trivial. The hard part is everything upstream: the consent flow, the bank connectors, the regulatory absorption that lets the integrator skip eIDAS entirely. That's the layer worth building if you want agents to actually reach banking data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The reason your agent can read your GitHub but not your bank statement isn't a missing integration. It's a regulatory design choice — the certificate tax — that funnels all third-party bank access through a handful of intermediaries. As the MCP ecosystem keeps eating every other API surface, banking is going to be the conspicuous holdout unless we build read tiers that don't require every integrator to become a regulated institution.&lt;/p&gt;

&lt;p&gt;Certificate-free, consent-bound, read-first access is the unlock. If you're building agent tooling and you've bounced off banking, that's the gap worth closing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I maintain open-banking.io, a certificate-free open banking API. The views on PSD2/eIDAS here are my own; the agent-native banking question is one I think about daily.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcpaiapisopensource</category>
    </item>
  </channel>
</rss>
