<?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>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 read their account data. 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 cert-free aggregators 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 costs more than many side projects earn in a year. 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 dominated by aggregators — Yapily, TrueLayer, Tink, Plaid. It's not that they have better technology. It's that they're the only entities willing and able to absorb the certificate tax.&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>
    <item>
      <title>GoCardless Bank Account Data Alternatives: What to Use When Signups Are Disabled</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Fri, 24 Jul 2026 03:12:54 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/gocardless-bank-account-data-alternatives-what-to-use-when-signups-are-disabled-326d</link>
      <guid>https://dev.to/johnfrandsen/gocardless-bank-account-data-alternatives-what-to-use-when-signups-are-disabled-326d</guid>
      <description>&lt;p&gt;If you've been building on GoCardless Bank Account Data (formerly Nordigen's free tier), you may have noticed something concerning: &lt;strong&gt;new signups are disabled.&lt;/strong&gt; The official page at &lt;code&gt;bankaccountdata.gocardless.com/new-signups-disabled&lt;/code&gt; now blocks all new integrations.&lt;/p&gt;

&lt;p&gt;Existing users can continue, but if you're starting a new project or evaluating options, you need alternatives — today.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;GoCardless acquired Nordigen in 2023 and integrated its free bank-data API as "Bank Account Data." The free tier was hugely popular with indie developers, hobbyist projects, and small fintech tools — it offered EU PSD2 coverage without the upfront cost of enterprise providers.&lt;/p&gt;

&lt;p&gt;Now that door is closing for new users. This mirrors a broader pattern: free open banking tiers are disappearing across the industry. Plaid tightened its free tier, Tink went enterprise-only under Visa, and now Nordigen's free legacy is being sunset for new signups.&lt;/p&gt;

&lt;p&gt;The question isn't just "what's the alternative" — it's &lt;strong&gt;"what alternative doesn't lock me into the same cycle?"&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The alternatives compared
&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 required?&lt;/th&gt;
&lt;th&gt;Pricing model&lt;/th&gt;
&lt;th&gt;EU coverage&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;open-banking.io&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;❌ No&lt;/td&gt;
&lt;td&gt;Free tier + paid plans&lt;/td&gt;
&lt;td&gt;EU (PSD2)&lt;/td&gt;
&lt;td&gt;Devs who want no certificate hassle&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tink (Visa)&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;Enterprise pricing&lt;/td&gt;
&lt;td&gt;EU + global&lt;/td&gt;
&lt;td&gt;Large-scale production&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plaid&lt;/td&gt;
&lt;td&gt;✅ Yes (EU)&lt;/td&gt;
&lt;td&gt;Pay-per-call&lt;/td&gt;
&lt;td&gt;Global&lt;/td&gt;
&lt;td&gt;US-first teams needing EU too&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Yapily&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;Custom pricing&lt;/td&gt;
&lt;td&gt;EU&lt;/td&gt;
&lt;td&gt;Enterprise integrations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enable Banking&lt;/td&gt;
&lt;td&gt;✅ Yes&lt;/td&gt;
&lt;td&gt;Custom pricing&lt;/td&gt;
&lt;td&gt;EU&lt;/td&gt;
&lt;td&gt;Budget-conscious teams&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key differentiator isn't coverage or pricing — it's the &lt;strong&gt;certificate requirement&lt;/strong&gt;. Most providers require you to hold an eIDAS QWAC/QSeal certificate to call their APIs directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why certificate-free matters more than you think
&lt;/h2&gt;

&lt;p&gt;An eIDAS qualified certificate costs &lt;strong&gt;€2,000–10,000 per year&lt;/strong&gt;, depending on the issuer and type. You need to renew every 1–2 years, maintain audit trails, and handle the operational overhead of certificate rotation.&lt;/p&gt;

&lt;p&gt;For an indie developer or small SMB tool, the certificate cost can exceed the &lt;strong&gt;entire app's revenue&lt;/strong&gt;. This is the hidden tax that makes "free" open banking APIs expensive in practice — you're paying for the cert even if the API calls are free.&lt;/p&gt;

&lt;p&gt;Certificate-free alternatives work differently: they use end-to-end encryption through regulated partner infrastructure, so you get PSD2-compliant bank data access without holding the certificate yourself. You interact with a standard REST API; the regulated partner handles the compliance layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to migrate from GoCardless BAD
&lt;/h2&gt;

&lt;p&gt;If you've been using the Nordigen/GoCardless Bank Account Data REST API, the migration path is straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;API surface is similar.&lt;/strong&gt; Most PSD2 providers expose endpoints for accounts, transactions, and balances. Endpoint structures differ slightly but the data model is consistent across PSD2.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Re-map bank identifiers.&lt;/strong&gt; Each provider uses slightly different bank ID schemas. You'll need to update your bank selection/lookup logic.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Consent flow is similar.&lt;/strong&gt; PSD2 mandates a redirect-based consent flow (ASPSP → callback). The mechanics are the same; the redirect URLs and token formats differ.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test incrementally.&lt;/strong&gt; Start with one bank, verify transaction data quality, then expand coverage.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The simplest path: pick a cert-free provider, spin up a sandbox, and point your existing integration at the new endpoints. If you were using Nordigen's Python SDK, the migration is mostly changing base URLs and auth headers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do right now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check your current integration status.&lt;/strong&gt; If you're an existing GoCardless BAD user, confirm your access is still active and understand the sunset timeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evaluate cert-free alternatives before your access ends.&lt;/strong&gt; Don't wait until the last minute — consent flows, bank mappings, and testing take time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you're starting fresh, skip the cert entirely.&lt;/strong&gt; There's no reason to buy a €2,000+/year certificate when cert-free options exist with the same PSD2 coverage.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I'm affiliated with open-banking.io, a certificate-free EU open banking API. This comparison is based on publicly available information about each provider as of July 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>banking</category>
    </item>
    <item>
      <title>Five Ways to Access European Bank Data in 2026 (And What Each Actually Costs)</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Thu, 23 Jul 2026 01:14:48 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/five-ways-to-access-european-bank-data-in-2026-and-what-each-actually-costs-130d</link>
      <guid>https://dev.to/johnfrandsen/five-ways-to-access-european-bank-data-in-2026-and-what-each-actually-costs-130d</guid>
      <description>&lt;h2&gt;
  
  
  The Five Paths to European Bank Data (And What Each One Actually Costs)
&lt;/h2&gt;

&lt;p&gt;If you're building a fintech app in Europe, you need bank transaction data. The PSD2 directive guarantees access — but &lt;em&gt;how&lt;/em&gt; you get that access determines your cost, complexity, and time-to-market.&lt;/p&gt;

&lt;p&gt;I've spent the last year navigating this landscape. Here's an honest breakdown of every path available in 2026, with real numbers.&lt;/p&gt;




&lt;h3&gt;
  
  
  Path 1: Become a TPP yourself
&lt;/h3&gt;

&lt;p&gt;You register as an Account Information Service Provider (AISP) with your national regulator, obtain an eIDAS Qualified Website Authentication Certificate (QWAC) and Qualified Electronic Seal (QSEAL), and call bank APIs directly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it costs:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Cost (EUR/year)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;QWAC certificate&lt;/td&gt;
&lt;td&gt;500 – 2,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;QSEAL certificate&lt;/td&gt;
&lt;td&gt;500 – 2,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Regulatory registration &amp;amp; legal&lt;/td&gt;
&lt;td&gt;3,000 – 10,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-bank API integration&lt;/td&gt;
&lt;td&gt;Engineering time × N banks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Certificate renewal (annual)&lt;/td&gt;
&lt;td&gt;500 – 2,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The hidden cost:&lt;/strong&gt; every bank implements the Berlin Group NextGenPSD2 spec &lt;em&gt;differently&lt;/em&gt;. LHV handles SCA one way. BNP Paribas another. Revolut's consent flow is non-standard. You'll spend weeks per bank debugging redirect loops, signature format mismatches, and undocumented error codes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict:&lt;/strong&gt; Only makes sense if you're a funded company with a dedicated compliance team and plan to operate at scale across many markets.&lt;/p&gt;




&lt;h3&gt;
  
  
  Path 2: Use a licensed aggregator (Plaid, TrueLayer, Yapily)
&lt;/h3&gt;

&lt;p&gt;These companies hold the TPP licenses and certificates. You integrate their SDK, and they handle the regulatory burden.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it costs:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plaid:&lt;/strong&gt; Usage-based, typically $0.50–$2.00 per connected account per month. Minimum commitments for production access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TrueLayer:&lt;/strong&gt; Pay-per-call or revenue-share models. Free tier for development.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Yapily:&lt;/strong&gt; Per-call pricing with volume discounts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The upside:&lt;/strong&gt; Polished SDKs, good documentation, broad bank coverage. You're up and running in days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The downside:&lt;/strong&gt; You're paying a premium per-transaction. For a personal finance app with 1,000 users syncing monthly, that's $500–$2,000/month just for data access. And you're locked into their API surface — if they change pricing or deprecate endpoints, your users feel it.&lt;/p&gt;




&lt;h3&gt;
  
  
  Path 3: Nordigen / GoCardless Bank Account Data
&lt;/h3&gt;

&lt;p&gt;Nordigen pioneered a generous free tier for bank data access. After the GoCardless acquisition, the free tier has been scaled back, and the long-term pricing direction is toward the GoCardless Open Banking product line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it costs:&lt;/strong&gt; Free tier exists (limited), paid plans start around €100–€500/month depending on volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The risk:&lt;/strong&gt; The acquisition means pricing and feature decisions are now made by a large payments company optimizing for enterprise accounts. If you're an indie developer or small startup, you're not the priority customer.&lt;/p&gt;




&lt;h3&gt;
  
  
  Path 4: Enable Banking
&lt;/h3&gt;

&lt;p&gt;A Finnish aggregator with a developer-friendly API and transparent per-bank pricing. Good coverage of Nordic and Baltic banks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What it costs:&lt;/strong&gt; Per-API-call pricing. Roughly €0.05–€0.15 per call depending on volume.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The upside:&lt;/strong&gt; Honest pricing, good docs, responsive support.&lt;br&gt;
&lt;strong&gt;The downside:&lt;/strong&gt; You still pay per-call, and costs scale linearly with user count.&lt;/p&gt;


&lt;h3&gt;
  
  
  Path 5: Cert-free proxy services
&lt;/h3&gt;

&lt;p&gt;This is the model I work on — services that sit between you and the bank APIs, absorbing the eIDAS certificate requirement entirely. You get a simple REST API key and never touch a certificate.&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;urllib.request&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;

&lt;span class="c1"&gt;# No certificate. No mTLS. Just an API key.
&lt;/span&gt;&lt;span class="n"&gt;API_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your-api-key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="c1"&gt;# Create a session
&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;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&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/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;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REVOLUT&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://yourapp.com/callback&lt;/span&gt;&lt;span class="sh"&gt;"&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="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;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="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/json&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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&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="n"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&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="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="c1"&gt;# User completes SCA redirect, then:
# Fetch transactions
&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;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&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&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="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="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&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="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What it costs:&lt;/strong&gt; Subscription-based, typically €2–€5/month per application regardless of call volume. No per-transaction fees.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The upside:&lt;/strong&gt; Fixed cost means it scales with users without scaling your bill. No certificate management. Standard REST API — works with any HTTP client.&lt;br&gt;
&lt;strong&gt;The downside:&lt;/strong&gt; You're trusting a third party with the TPP layer. Less granular control than direct bank API access.&lt;/p&gt;




&lt;h3&gt;
  
  
  How to choose
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Your situation&lt;/th&gt;
&lt;th&gt;Recommended path&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Funded company, multi-market, compliance team&lt;/td&gt;
&lt;td&gt;TPP license + direct APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quick prototype, any budget&lt;/td&gt;
&lt;td&gt;Aggregator (Plaid/TrueLayer)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost-sensitive, per-call OK&lt;/td&gt;
&lt;td&gt;Enable Banking&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixed budget, indie/small team&lt;/td&gt;
&lt;td&gt;Cert-free proxy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise, needs payment initiation too&lt;/td&gt;
&lt;td&gt;Yapily or TrueLayer&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The key insight: &lt;strong&gt;the cheapest API call is the one where the certificate cost is amortized across thousands of users.&lt;/strong&gt; If you're below that scale, a cert-free proxy gives you the same data at a fraction of the cost.&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 open banking API service. This article reflects my experience building in this space — the cost figures come from public pricing pages and my own integration work. I've tried to be honest about the tradeoffs of every approach, including the one I work on.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openbanking</category>
      <category>fintech</category>
      <category>api</category>
      <category>psd2</category>
    </item>
    <item>
      <title>Reading EU Bank Transactions With Python and PSD2 — Without Buying an eIDAS Certificate</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Wed, 22 Jul 2026 05:09:27 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/reading-eu-bank-transactions-with-python-and-psd2-without-buying-an-eidas-certificate-io3</link>
      <guid>https://dev.to/johnfrandsen/reading-eu-bank-transactions-with-python-and-psd2-without-buying-an-eidas-certificate-io3</guid>
      <description>&lt;p&gt;If you've tried to build anything with European bank APIs, you probably hit the same wall I did: the certificate requirement. PSD2 says you need an eIDAS QWAC certificate to talk to banks directly, and those aren't cheap or quick to obtain.&lt;/p&gt;

&lt;p&gt;But there's a path that skips the certificate entirely. I'll show you how it works — with real code you can run today.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Instead of obtaining your own eIDAS certificate and registering as a Third Party Provider with every bank, you use an &lt;strong&gt;Account Information Service Provider (AISP)&lt;/strong&gt; that already has the certificate. Your app talks to their API; they talk to the banks. You never touch a certificate.&lt;/p&gt;

&lt;p&gt;The trade-off: you depend on an intermediary. But for most indie projects, small businesses, and personal-finance tools, that's a perfectly reasonable trade. You get bank data via a clean REST API instead of fighting with mutual TLS and per-bank registration.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you'll need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Python 3.8+ (stdlib only, no pip installs needed for the basics)&lt;/li&gt;
&lt;li&gt;An API key from an AISP provider&lt;/li&gt;
&lt;li&gt;A bank account in a supported EU country&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm the maintainer of &lt;a href="https://open-banking.io" rel="noopener noreferrer"&gt;open-banking.io&lt;/a&gt;, so I'll use our API as the example — but the pattern is the same for any PSD2 AISP (Enable Banking, Salt Edge, Tink, etc.). Pick whichever fits your coverage and budget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Get your API key
&lt;/h2&gt;

&lt;p&gt;Sign up, grab your API key. Store it as an environment variable:&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="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OBI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"***"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never hardcode it. If you commit it to git, rotate immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Initiate a bank connection
&lt;/h2&gt;

&lt;p&gt;Your user needs to consent to sharing their data. This redirects them to their bank's login page:&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;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt;

&lt;span class="n"&gt;API_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="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;env&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="n"&gt;EY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;]

def create_session(bank_id: str, return_url: str) -&amp;gt; dict:
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Start a bank connection. Returns a consent URL for the user.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    payload = json.dumps({
        &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="n"&gt;bank_id&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="n"&gt;return_url&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: return_url,
    }).encode()

    req = urllib.request.Request(
        f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API_BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;sessions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,
        data=payload,
        headers={
            &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="n"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="n"&gt;Bearer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;API_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,
            &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="n"&gt;Content&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="n"&gt;application&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,
        },
        method=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="n"&gt;POST&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;,
    )

    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read())

# Example: connect to a Danish bank
result = create_session(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="n"&gt;nordea&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;dk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="n"&gt;https&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;//&lt;/span&gt;&lt;span class="n"&gt;yourapp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;callback&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;)
print(f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="n"&gt;Send&lt;/span&gt; &lt;span class="n"&gt;the&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user visits &lt;code&gt;consent_url&lt;/code&gt;, logs into their bank, and approves. They're redirected back to your &lt;code&gt;return_url&lt;/code&gt; with a session token.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Fetch accounts and balances
&lt;/h2&gt;

&lt;p&gt;Once the session is authorized:&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;get_accounts&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="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;list&lt;/span&gt;&lt;span class="p"&gt;:&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;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&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;/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="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="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&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="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;resp&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&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;read&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_balance&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="nb"&gt;str&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="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;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&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;/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;/balance&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;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="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&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="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;resp&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&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;read&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="nf"&gt;get_accounts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&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="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="n"&gt;bal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_balance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&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;name&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;bal&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="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;bal&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;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;h2&gt;
  
  
  Step 4: Fetch 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;get_transactions&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="nb"&gt;str&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="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="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&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;/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;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;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="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&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="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;resp&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&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;read&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;for&lt;/span&gt; &lt;span class="n"&gt;txn&lt;/span&gt; &lt;span class="ow"&gt;in&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;result&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;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="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;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;date&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="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="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;8&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="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;description&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="mi"&gt;50&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. You're reading live bank data without ever touching a certificate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas worth knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Consent expiry.&lt;/strong&gt; PSD2 consents expire (typically 90 days, varies by bank). You'll need to re-authorize periodically. Build that into your app's flow — don't assume a session lasts forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Booked vs pending.&lt;/strong&gt; Transactions come in two flavors. &lt;code&gt;booked&lt;/code&gt; transactions are finalized; &lt;code&gt;pending&lt;/code&gt; are provisional and may change or disappear. If you're building a budgeting tool, track &lt;code&gt;booked&lt;/code&gt; as your source of truth and dedupe carefully — the same transaction can appear in both lists during the settlement window.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bank coverage gaps.&lt;/strong&gt; No AISP covers every EU bank. Before committing, check the provider's coverage list for the banks your users actually need. Smaller regional banks are often the gap.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rate limits.&lt;/strong&gt; PSD2 APIs enforce per-consent access frequency limits (typically 4x/day for transactions). Cache aggressively. Don't poll in a loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Privacy model.&lt;/strong&gt; Some providers store your transaction data server-side. Others (like ours) use client-held encryption keys so the provider literally can't read user data. If you're building for GDPR-conscious users, check which model you're getting.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you &lt;em&gt;should&lt;/em&gt; get the certificate
&lt;/h2&gt;

&lt;p&gt;If you're building a regulated financial product at scale — processing payments (PIS), doing credit checks, or serving thousands of users across many banks — get your own eIDAS certificate and go direct. The intermediary adds latency, cost per call, and a dependency you don't want at scale.&lt;/p&gt;

&lt;p&gt;But for personal projects, small SaaS tools, internal dashboards, and anything where you just need to &lt;em&gt;read&lt;/em&gt; account data without the enterprise overhead: the AISP path is the pragmatic choice.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Disclosure: I'm John, the maintainer of &lt;a href="https://open-banking.io" rel="noopener noreferrer"&gt;open-banking.io&lt;/a&gt; — a certificate-free PSD2 account information service for EU banks. The code above works with our API, but the pattern applies to any compliant AISP. Questions? Find me in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>openbanking</category>
      <category>api</category>
      <category>fintech</category>
    </item>
    <item>
      <title>Open Banking Without an eIDAS Certificate: A Practical Developer's Guide (2026)</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Tue, 21 Jul 2026 23:11:52 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/open-banking-without-an-eidas-certificate-a-practical-developers-guide-2026-1j6</link>
      <guid>https://dev.to/johnfrandsen/open-banking-without-an-eidas-certificate-a-practical-developers-guide-2026-1j6</guid>
      <description>&lt;p&gt;If you've ever tried to call a European bank's open banking endpoint directly — say, to read your own account balances programmatically — you probably hit a wall that looks something like this:&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="err"&gt;HTTP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Unauthorized&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"invalid_client"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"error_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;"mutual TLS certificate required"&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;That's the &lt;strong&gt;eIDAS certificate&lt;/strong&gt; gate. Under PSD2, a bank's production API is protected by mutual TLS using a qualified certificate (a &lt;strong&gt;QWAC&lt;/strong&gt;), and request signing often needs a second one (&lt;strong&gt;QSeal&lt;/strong&gt;). For a regulated bank or a well-funded fintech, that's a tax you pay. For an indie developer, a student project, or a small business automating its own accounting, it's a deal-breaker: the certs cost roughly &lt;strong&gt;EUR 2,000–10,000 per year&lt;/strong&gt;, take weeks of paperwork, and require a registered legal entity.&lt;/p&gt;

&lt;p&gt;The good news: there's a well-defined, fully compliant path to read EU bank data &lt;strong&gt;without ever touching an eIDAS certificate yourself&lt;/strong&gt;. This guide explains how it actually works under the hood, when to use it, and includes real, runnable code.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclosure up front:&lt;/strong&gt; I'm John, the founder of &lt;a href="https://open-banking.io" rel="noopener noreferrer"&gt;open-banking.io&lt;/a&gt; — one of the providers in this space. I'll use our API for the worked code example because I know it best, but the architecture I'm describing is how &lt;strong&gt;every&lt;/strong&gt; certificate-free aggregator works. I'll be honest about where direct access is the better choice.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The two paths to a bank's API
&lt;/h2&gt;

&lt;p&gt;Every PSD2 Account Information Service (AIS) call ultimately lands on the same bank endpoint. The question is &lt;em&gt;who holds the certificate&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Path A — Direct (you hold the eIDAS certificate)
&lt;/h3&gt;

&lt;p&gt;You apply for an eIDAS QWAC and QSeal from a qualified trust service provider, register as a TPP (Third Party Provider) with each national regulator, onboard with each bank's developer portal, and call the bank directly over mutual TLS.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; EUR 2k–10k/year per certificate + legal/regulatory overhead&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to first call:&lt;/strong&gt; 4–12 weeks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coverage:&lt;/strong&gt; one bank cluster at a time (you onboard bank-by-bank)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for:&lt;/strong&gt; regulated fintechs with high volume, enterprise treasuries, anyone who needs to avoid a data intermediary in the path&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Path B — Aggregator (the aggregator holds the certificate)
&lt;/h3&gt;

&lt;p&gt;You sign up with an AIS aggregator, get an API key, and call &lt;em&gt;their&lt;/em&gt; unified API. They hold the eIDAS certificates, maintain the bank integrations, handle the per-bank quirks (Berlin Group vs STET vs UK Open Banking vs Polish API standards), and present you with one consistent REST interface.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost:&lt;/strong&gt; free tiers exist (small projects), usage-based pricing at scale&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to first call:&lt;/strong&gt; minutes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coverage:&lt;/strong&gt; hundreds/thousands of banks across the EU/EEA through one integration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for:&lt;/strong&gt; indie devs, SMBs, PFM apps, self-hosters, anyone who values shipping over regulatory plumbing&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Direct (eIDAS)&lt;/th&gt;
&lt;th&gt;Aggregator (cert-free)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Certificate required?&lt;/td&gt;
&lt;td&gt;QWAC + QSeal (you buy)&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup time&lt;/td&gt;
&lt;td&gt;Weeks–months&lt;/td&gt;
&lt;td&gt;Minutes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upfront cost&lt;/td&gt;
&lt;td&gt;EUR 2k–10k/yr&lt;/td&gt;
&lt;td&gt;$0 on free tiers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bank coverage&lt;/td&gt;
&lt;td&gt;Per-bank onboarding&lt;/td&gt;
&lt;td&gt;1,000s of banks, one API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data path&lt;/td&gt;
&lt;td&gt;You &amp;lt;-&amp;gt; Bank&lt;/td&gt;
&lt;td&gt;You &amp;lt;-&amp;gt; Aggregator &amp;lt;-&amp;gt; Bank&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GDPR control&lt;/td&gt;
&lt;td&gt;Full (no intermediary)&lt;/td&gt;
&lt;td&gt;Depends on provider (look for EU data residency + E2E encryption)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-bank quirks&lt;/td&gt;
&lt;td&gt;You handle them all&lt;/td&gt;
&lt;td&gt;Abstracted away&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The GDPR row is the one people underestimate. A good aggregator gives you &lt;strong&gt;EU-only data residency&lt;/strong&gt; and ideally &lt;strong&gt;end-to-end encryption where only &lt;em&gt;you&lt;/em&gt; hold the decryption key&lt;/strong&gt; — meaning the aggregator genuinely can't read your transactions in the clear. That's the property you're trading the direct path for, so verify it before you sign up.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the cert-free flow actually works
&lt;/h2&gt;

&lt;p&gt;Aggregators don't "skip" PSD2 — they comply with it &lt;em&gt;on your behalf&lt;/em&gt;. Here's the sequence, and it's the same whether you use Nordigen/GoCardless, Tink, TrueLayer, Enable Banking, or open-banking.io:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Create a consent / requisition      -&amp;gt; you POST "user wants bank X"
2. Redirect user to bank (SCA)         -&amp;gt; user authenticates at their bank
3. Bank calls back to aggregator       -&amp;gt; consent is now "valid"
4. You list accounts                   -&amp;gt; GET /accounts
5. You fetch balances &amp;amp; transactions   -&amp;gt; GET /accounts/{id}/transactions
6. Consent expires (90 / 180 days)     -&amp;gt; repeat from step 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The eIDAS certificate is used in step 2 and 3 — when the aggregator talks to the bank's production endpoints. Your API key only authenticates &lt;em&gt;you&lt;/em&gt; to the aggregator, over standard HTTPS. You never see mTLS.&lt;/p&gt;

&lt;p&gt;The two things that bite people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SCA (Strong Customer Authentication):&lt;/strong&gt; the user must actively authenticate at their bank — a redirect, an app-to-app handoff, or a decoupled push. You can't silently scrape. Plan your UX around a browser/redirect hop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consent expiry:&lt;/strong&gt; PSD2 limits AIS consent. Common values are &lt;strong&gt;90 days&lt;/strong&gt; (Germany) or &lt;strong&gt;180 days&lt;/strong&gt; (many other markets), and users can revoke at any time. Your code must handle re-consent gracefully — store the consent expiry timestamp alongside the consent id and trigger re-auth &lt;em&gt;before&lt;/em&gt; it lapses.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Real code: your first cert-free bank-data call
&lt;/h2&gt;

&lt;p&gt;Here's the actual pattern, using &lt;a href="https://open-banking.io" rel="noopener noreferrer"&gt;open-banking.io&lt;/a&gt;'s API as the worked example. The shape (create requisition -&amp;gt; redirect -&amp;gt; list -&amp;gt; fetch) is identical across providers; only the field names change.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1 — Create a requisition (pick a bank)
&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;# Replace YOUR_API_KEY with the key from your provider's dashboard&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.open-banking.io/v1/requisitions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_API_KEY"&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;'{
    "redirect": "https://yourapp.example.com/callback",
    "institution_id": "DANMARKS_DANSKE_BANK",
    "reference": "user-42-first-link"
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"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;"req_8f3a..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"link"&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/redirect/req_8f3a..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"accounts"&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;"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;"CR"&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;Send the user's browser to &lt;code&gt;link&lt;/code&gt;. They authenticate at Danske Bank (SCA). On success, the bank redirects back to your &lt;code&gt;redirect&lt;/code&gt; URL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2 — List the linked accounts
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://api.open-banking.io/v1/requisitions/req_8f3a... &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_API_KEY"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"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;"req_8f3a..."&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;"LN"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"accounts"&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;"acc_a1b2..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"acc_c3d4..."&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;&lt;code&gt;status: "LN"&lt;/code&gt; means linked. Now grab data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3 — Fetch balances and transactions
&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;# Balances&lt;/span&gt;
curl https://api.open-banking.io/v1/accounts/acc_a1b2.../balances &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_API_KEY"&lt;/span&gt;

&lt;span class="c"&gt;# Transactions (last 30 days)&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://api.open-banking.io/v1/accounts/acc_a1b2.../transactions?date_from=2026-06-21&amp;amp;date_to=2026-07-21"&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;"Authorization: Bearer YOUR_API_KEY"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A tiny Python client
&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;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.open-banking.io/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;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;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;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;def&lt;/span&gt; &lt;span class="nf"&gt;create_requisition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;institution_id&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;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;/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="n"&gt;redirect_url&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;institution_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;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;list_accounts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requisition_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;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;/requisitions/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;requisition_id&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="n"&gt;H&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;accounts&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;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;date_from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;date_to&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="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;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="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="n"&gt;date_from&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="n"&gt;date_to&lt;/span&gt;&lt;span class="p"&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;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="c1"&gt;# Usage
&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;create_requisition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DANMARKS_DANSKE_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;https://yourapp.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="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 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;# ... after the user authenticates at their 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="nf"&gt;list_accounts&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;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="nf"&gt;get_transactions&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;2026-06-21&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-21&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="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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Check each provider's live docs for the exact field names — they vary. The &lt;em&gt;flow&lt;/em&gt; above is universal across PSD2 AIS.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Five gotchas that cost me real time
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Consent expiry is per-country, not universal.&lt;/strong&gt; Germany defaults to 90 days; the Nordics often allow 180. Store the expiry &lt;em&gt;per consent&lt;/em&gt; and notify users at T-7, T-3, and T-0. A silent expiry is the #1 reason "my sync stopped working" tickets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bank coverage has real gaps.&lt;/strong&gt; "Supports 10,000 banks" hides the fact that your specific regional Sparkasse or niche neobank may be missing or sandbox-only. Test your &lt;em&gt;actual&lt;/em&gt; target banks before committing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transaction booking vs pending.&lt;/strong&gt; Most APIs expose both &lt;code&gt;booked&lt;/code&gt; and &lt;code&gt;pending&lt;/code&gt; transactions. Dedupe by &lt;code&gt;transactionId&lt;/code&gt; (or &lt;code&gt;entryReference&lt;/code&gt;); if a bank doesn't supply one, hash &lt;code&gt;(date, amount, counterparty, description)&lt;/code&gt; and treat collisions carefully.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limits and polling.&lt;/strong&gt; Refreshing transactions every minute will get you throttled — and PSD2 limits how often you can pull anyway. Once or twice a day per account is usually plenty; some banks also support webhooks for new transactions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GDPR / data residency.&lt;/strong&gt; If your users are EU-based and you're shipping a commercial product, confirm the aggregator processes data in the EU and offers end-to-end encryption. "Compliant with PSD2" is not the same as "we can't read your data."&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  When should you actually get the certificate?
&lt;/h2&gt;

&lt;p&gt;The aggregator path isn't always right. Go direct with your own eIDAS certificate when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're a regulated AIS/PISP and &lt;strong&gt;volume justifies it&lt;/strong&gt; (rough rule of thumb: once you're paying the aggregator more than ~EUR 2k/year, the math flips).&lt;/li&gt;
&lt;li&gt;You're an enterprise treasury or bank-adjacent product where &lt;strong&gt;no third party may sit in the data path&lt;/strong&gt; (regulatory or policy reasons).&lt;/li&gt;
&lt;li&gt;You need &lt;strong&gt;Payment Initiation (PIS)&lt;/strong&gt; at high reliability and the aggregator's PIS coverage is thin for your target banks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For everyone else — hobby projects, SMB accounting automation, personal finance dashboards, self-hosted budgeting tools (Actual Budget, Firefly III, Beancount integrations) — the certificate-free aggregator path is almost always the pragmatic choice. You get to ship this week instead of next quarter.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;You don't need an eIDAS certificate to read EU bank data. An AIS aggregator holds it for you; you get an API key.&lt;/li&gt;
&lt;li&gt;The flow is universal: &lt;strong&gt;create requisition -&amp;gt; SCA redirect -&amp;gt; list accounts -&amp;gt; fetch transactions -&amp;gt; renew consent before it expires.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Pick a provider on three axes: &lt;strong&gt;bank coverage for &lt;em&gt;your&lt;/em&gt; banks&lt;/strong&gt;, &lt;strong&gt;EU data residency + E2E encryption&lt;/strong&gt;, and &lt;strong&gt;a free tier that fits your stage&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Go direct (buy the cert) only when volume, regulation, or a no-intermediary requirement demands it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to try the flow above end-to-end, you can grab a free API key at &lt;a href="https://open-banking.io" rel="noopener noreferrer"&gt;open-banking.io&lt;/a&gt; — and yes, that's my project, so apply appropriate skepticism and compare it against Tink, TrueLayer, GoCardless (formerly Nordigen), and Enable Banking before you commit. The right answer depends on your banks and your budget, not on who wrote this article.&lt;/p&gt;

&lt;p&gt;Questions or war stories from your own bank-data integrations? Drop them in the comments — I read every one.&lt;/p&gt;

</description>
      <category>openbanking</category>
      <category>api</category>
      <category>fintech</category>
      <category>banking</category>
    </item>
    <item>
      <title>Why You Cant Just Call a Bank API: Open Banking Authentication Explained for Developers</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Thu, 16 Jul 2026 00:52:34 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/why-you-cant-just-call-a-bank-api-open-banking-authentication-explained-for-developers-5e9a</link>
      <guid>https://dev.to/johnfrandsen/why-you-cant-just-call-a-bank-api-open-banking-authentication-explained-for-developers-5e9a</guid>
      <description>&lt;p&gt;If you've ever tried to hit a bank's API directly and hit a wall of certificates, registration forms, and regulatory compliance documents — you're not alone. Open banking APIs promise programmatic access to financial data, but the authentication layer is where most developers get stuck.&lt;/p&gt;

&lt;p&gt;Here's what's actually going on, and why most teams end up using an aggregator instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three layers of open banking auth
&lt;/h2&gt;

&lt;p&gt;Open banking authentication isn't one thing — it's three things stacked on top of each other:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. eIDAS / QWAC certificates (the regulatory gate)
&lt;/h3&gt;

&lt;p&gt;If you want to talk to a bank's API directly in the EU/UK, you need an &lt;strong&gt;eIDAS Qualified Certificate&lt;/strong&gt; (specifically a QWAC — Qualified Website Authentication Certificate). This proves you're a regulated Third Party Provider (TPP).&lt;/p&gt;

&lt;p&gt;The cost? Anywhere from €2,000–€10,000 per year, depending on the issuer. Plus the regulatory registration process, which can take months.&lt;/p&gt;

&lt;p&gt;This is the single biggest barrier for indie developers and small companies. You can't just &lt;code&gt;curl&lt;/code&gt; a bank endpoint — the TLS handshake requires a client certificate the bank recognizes.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. OAuth 2.0 + SCA (the user consent flow)
&lt;/h3&gt;

&lt;p&gt;Once you're past the certificate gate, every bank interaction starts with an &lt;strong&gt;OAuth 2.0 Authorization Code flow&lt;/strong&gt; with &lt;strong&gt;Strong Customer Authentication (SCA)&lt;/strong&gt;:&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="err"&gt;GET /authorize?
  response_type=code&amp;amp;
  client_id=your_client_id&amp;amp;
  redirect_uri=https://yourapp.com/callback&amp;amp;
  scope=accounts&amp;amp;
  state=random_state
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user is redirected to their bank, authenticates (2FA — usually a push notification or SMS code), and consents to sharing specific accounts. You get back an authorization code, exchange it for access + refresh tokens, and use the access token for subsequent API calls.&lt;/p&gt;

&lt;p&gt;Each bank implements this slightly differently. The Open Banking Standard in the UK enforces a consistent flow, but continental European banks vary widely.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Bank-specific API quirks (the implementation tax)
&lt;/h3&gt;

&lt;p&gt;Even with certificates and OAuth sorted, each bank's API has its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Response formats&lt;/strong&gt; (some use Berlin Group NextGenPSD2, some use STET, some use the UK Open Banking standard)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pagination schemes&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Transaction ID formats&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Rate limits&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Error response structures&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sandbox vs production differences&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is why companies like Plaid, Tink, TrueLayer, and GoCardless/Nordigen exist — they abstract away all three layers behind a single, consistent API.&lt;/p&gt;

&lt;h2&gt;
  
  
  The aggregator model
&lt;/h2&gt;

&lt;p&gt;An aggregator (also called an AISP — Account Information Service Provider) holds the eIDAS certificate, maintains connections to hundreds of banks, and exposes a unified REST API:&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;# One API key, no certificates
&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer obi_your_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;response&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/{account_id}/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;headers&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;transactions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&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;You authenticate with a simple &lt;strong&gt;bearer token&lt;/strong&gt;. The aggregator handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The eIDAS certificate and TPP registration&lt;/li&gt;
&lt;li&gt;The OAuth + SCA consent redirect&lt;/li&gt;
&lt;li&gt;Normalizing 3,000+ bank APIs into one consistent schema&lt;/li&gt;
&lt;li&gt;Handling token refresh, reconnection, and rate limits&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trade-off: you pay per API call or a monthly fee, and you trust the aggregator with credentials/access to your financial data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Certificate-free aggregators: a newer option
&lt;/h2&gt;

&lt;p&gt;Most aggregators still require you to go through their onboarding, which can involve KYC, compliance checks, and minimum volume commitments. But a newer category — &lt;strong&gt;certificate-free aggregators&lt;/strong&gt; — strips even that down.&lt;/p&gt;

&lt;p&gt;Services like &lt;a href="https://open-banking.io" rel="noopener noreferrer"&gt;open-banking.io&lt;/a&gt; provide a bearer-token REST API where:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No eIDAS certificate is needed (the aggregator holds it)&lt;/li&gt;
&lt;li&gt;No minimum volume or enterprise contract&lt;/li&gt;
&lt;li&gt;You sign up, get an API key, and start calling&lt;/li&gt;
&lt;li&gt;Pricing is usage-based (from a few euros/month)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This makes it viable for hobby projects, personal finance dashboards, and SMB integrations that can't justify the cost of a full enterprise aggregator contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to use what
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Effort&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;Direct bank API (own eIDAS cert)&lt;/td&gt;
&lt;td&gt;€2k–€10k/yr + regulatory work&lt;/td&gt;
&lt;td&gt;Very high&lt;/td&gt;
&lt;td&gt;Banks, large fintechs, regulated TPPs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise aggregator (Plaid, Tink, etc.)&lt;/td&gt;
&lt;td&gt;Negotiated, often $500+/mo&lt;/td&gt;
&lt;td&gt;Medium (KYC + integration)&lt;/td&gt;
&lt;td&gt;Funded startups, established fintechs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Certificate-free aggregator&lt;/td&gt;
&lt;td&gt;€3–€50/mo, usage-based&lt;/td&gt;
&lt;td&gt;Low (API key + REST calls)&lt;/td&gt;
&lt;td&gt;Indie devs, SMBs, self-hosted tools&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Practical example: reading transactions
&lt;/h2&gt;

&lt;p&gt;Here's what the full flow looks like with a certificate-free aggregator:&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.open-banking.io/v1&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer obi_your_api_key&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. List available institutions
&lt;/span&gt;&lt;span class="n"&gt;institutions&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;/institutions?country=DE&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# 2. Create an authorization link (user consents via their bank)
&lt;/span&gt;&lt;span class="n"&gt;auth&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;/authorization&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;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;institutions&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;redirect_uri&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://yourapp.com/callback&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="c1"&gt;# User visits auth["link"], authenticates with their bank, returns to your redirect_uri
&lt;/span&gt;
&lt;span class="c1"&gt;# 3. Fetch transactions (after the user completes consent)
&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&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;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;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;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="n"&gt;HEADERS&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;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;txns&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="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;t&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="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;t&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="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mf"&gt;10.2&lt;/span&gt;&lt;span class="n"&gt;f&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;t&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;description&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No certificates. No regulatory paperwork. One HTTP library.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;The "open" in open banking refers to the regulatory &lt;em&gt;mandate&lt;/em&gt; for banks to open their APIs — not to those APIs being easy to use. The authentication complexity is intentional: it protects users. But it also creates a natural market for aggregators that absorb that complexity.&lt;/p&gt;

&lt;p&gt;If you're building a fintech product with funding and regulatory backing, going direct with your own eIDAS certificate makes sense. For everyone else — indie developers, small businesses, self-hosted personal finance tools — an aggregator (especially a certificate-free one) is almost always the right call.&lt;/p&gt;

&lt;p&gt;The key is understanding what you're paying for: not just API access, but the entire regulatory and integration layer that makes that access possible.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I build &lt;a href="https://open-banking.io" rel="noopener noreferrer"&gt;open-banking.io&lt;/a&gt; — a certificate-free open banking API for EU/UK bank data. No eIDAS certificates, no minimums, just a bearer token and REST calls.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
    </item>
    <item>
      <title>Nordigen Alternatives in 2026: A Developer's Guide to European Bank Data APIs</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Fri, 10 Jul 2026 10:30:14 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/nordigen-alternatives-in-2026-a-developers-guide-to-european-bank-data-apis-3pie</link>
      <guid>https://dev.to/johnfrandsen/nordigen-alternatives-in-2026-a-developers-guide-to-european-bank-data-apis-3pie</guid>
      <description>&lt;p&gt;Nordigen (now part of GoCardless) pioneered free PSD2 account information services in Europe. But as the open banking landscape matures in 2026, developers and small teams are evaluating alternatives for good reasons: pricing changes after acquisition, rate limits on free tiers, and the question of whether you need an aggregator at all versus connecting directly to bank APIs.&lt;/p&gt;

&lt;p&gt;This guide compares the main Nordigen alternatives for accessing European bank account data, with a focus on what matters to developers and small businesses: cost, certificate requirements, bank coverage, and self-hosting options.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Look Beyond Nordigen?
&lt;/h2&gt;

&lt;p&gt;Nordigen was acquired by GoCardless in 2022, and since then the product has been integrated into the GoCardless platform. For developers who originally chose Nordigen for its free, developer-friendly API, several shifts have prompted a search for alternatives:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pricing uncertainty:&lt;/strong&gt; The free tier has become more restrictive, and pricing for production use is less transparent than it was as an independent company.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aggregator dependency:&lt;/strong&gt; Every request routes through Nordigen's infrastructure. If their service degrades, your application degrades.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data sovereignty:&lt;/strong&gt; Some teams prefer to hold bank API connections directly rather than routing through a third party.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Key Question: Aggregator or Direct API?
&lt;/h2&gt;

&lt;p&gt;Before comparing specific alternatives, it's worth understanding the two architectural approaches:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aggregators&lt;/strong&gt; (Nordigen, TrueLayer, Tink, Salt Edge) provide a unified API that abstracts away individual bank APIs. You integrate once and get access to hundreds of banks. The trade-off is an additional layer between you and the bank, plus per-API-call costs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direct API / Self-hosted&lt;/strong&gt; approaches let you connect to bank APIs yourself using PSD2 open banking standards. This eliminates the aggregator middleman and per-call fees but requires you to handle bank API differences, authentication flows, and — traditionally — an eIDAS QWAC certificate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nordigen Alternatives Compared
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. TrueLayer
&lt;/h3&gt;

&lt;p&gt;TrueLayer is the most prominent Nordigen competitor in the UK and European markets. They offer a polished API with strong documentation and broad bank coverage.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Coverage:&lt;/strong&gt; 1,500+ banks across Europe&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; Free sandbox; production pricing is usage-based, starting around £0.10-0.30 per successful API call depending on endpoint&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Certificate required:&lt;/strong&gt; No — TrueLayer handles PSD2 compliance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for:&lt;/strong&gt; Teams that want a premium managed API and can absorb per-call costs&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Tink (Visa)
&lt;/h3&gt;

&lt;p&gt;Tink was acquired by Visa in 2022 and offers account aggregation, payment initiation, and data enrichment. Their API is well-regarded for data quality and categorization features.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Coverage:&lt;/strong&gt; 6,000+ banks globally (including non-PSD2 markets)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; Enterprise-focused; limited free tier&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Certificate required:&lt;/strong&gt; No — Tink handles compliance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for:&lt;/strong&gt; Larger teams needing enrichment and cross-market coverage&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Salt Edge
&lt;/h3&gt;

&lt;p&gt;Salt Edge has been operating since 2013 and covers both European PSD2 banks and global banks outside the PSD2 framework.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Coverage:&lt;/strong&gt; 500+ banks in Europe, plus global coverage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; Tiered plans; developer sandbox available&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Certificate required:&lt;/strong&gt; No&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for:&lt;/strong&gt; Teams needing coverage outside Europe alongside PSD2 banks&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. GoCardless (Formerly Nordigen)
&lt;/h3&gt;

&lt;p&gt;After acquiring Nordigen, GoCardless integrated the open banking data API into their broader payment platform. The Nordigen API still works but is increasingly bundled with payment initiation products.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Coverage:&lt;/strong&gt; 2,300+ banks in 31 European countries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; Free tier still available (with limits); paid tiers bundled with GoCardless payment products&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Certificate required:&lt;/strong&gt; No&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for:&lt;/strong&gt; Teams already using GoCardless for direct debit payments&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. open-banking.io (Cert-Free Direct API)
&lt;/h3&gt;

&lt;p&gt;For developers who want to connect to European bank APIs directly without an aggregator — and without the eIDAS certificate cost — open-banking.io provides a PSD2-compliant API layer that handles the bank connection protocol without requiring you to purchase a €2,000-6,000 annual QWAC certificate.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Coverage:&lt;/strong&gt; European banks supporting Berlin Group NextGenPSD2 or UK Open Banking standards&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; No per-call fees; self-hostable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Certificate required:&lt;/strong&gt; No — the cert-free architecture is the core differentiator&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Best for:&lt;/strong&gt; Developers, self-hosters, and small businesses that want direct bank connections without aggregator markups or certificate overhead&lt;/li&gt;
&lt;/ul&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Nordigen/GoCardless&lt;/th&gt;
&lt;th&gt;TrueLayer&lt;/th&gt;
&lt;th&gt;Tink&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;Pricing model&lt;/td&gt;
&lt;td&gt;Free tier + usage&lt;/td&gt;
&lt;td&gt;Per-call&lt;/td&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;No per-call fees&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Certificate needed&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hostable&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bank coverage&lt;/td&gt;
&lt;td&gt;2,300+ EU&lt;/td&gt;
&lt;td&gt;1,500+ EU/UK&lt;/td&gt;
&lt;td&gt;6,000+ global&lt;/td&gt;
&lt;td&gt;EU PSD2 banks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data enrichment&lt;/td&gt;
&lt;td&gt;Basic&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Advanced&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Payment initiation&lt;/td&gt;
&lt;td&gt;Yes (via GoCardless)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Read-only focused&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  How to Choose
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose an aggregator (TrueLayer, Tink, Salt Edge)&lt;/strong&gt; if you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Maximum bank coverage with a single integration&lt;/li&gt;
&lt;li&gt;Data enrichment (merchant identification, categorization)&lt;/li&gt;
&lt;li&gt;Payment initiation alongside account data&lt;/li&gt;
&lt;li&gt;A managed service with SLAs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Choose a direct/self-hosted approach (open-banking.io)&lt;/strong&gt; if you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;No per-call or per-account fees&lt;/li&gt;
&lt;li&gt;Full control over your data pipeline&lt;/li&gt;
&lt;li&gt;Self-hosting on your own infrastructure&lt;/li&gt;
&lt;li&gt;Read-only account information without payment initiation complexity&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Stay with Nordigen/GoCardless&lt;/strong&gt; if you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Are already integrated and the API meets your needs&lt;/li&gt;
&lt;li&gt;Use GoCardless for payments and want unified billing&lt;/li&gt;
&lt;li&gt;Need the free tier for low-volume use&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Certificate Question
&lt;/h2&gt;

&lt;p&gt;The single biggest barrier to direct PSD2 API access has been the eIDAS QWAC (Qualified Website Authentication Certificate) requirement. These certificates cost €2,000-6,000 per year and require an extended validation process with an eIDAS-qualified trust service provider.&lt;/p&gt;

&lt;p&gt;This is precisely why aggregators like Nordigen gained traction: they absorb the certificate cost and complexity. The emerging alternatives — including cert-free approaches — aim to remove this barrier so that individual developers and small businesses can access bank data directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The Nordigen alternative landscape in 2026 is healthy and diverse. Managed aggregators like TrueLayer and Tink offer convenience and enrichment at a per-call cost. Direct API approaches like open-banking.io offer independence and cost predictability for teams willing to handle integration themselves. The right choice depends on your team size, budget, and whether you value managed convenience over architectural control.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What's your experience with Nordigen or other open banking APIs? I'd love to hear about your use case in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>api</category>
    </item>
    <item>
      <title>How to Sync Bank Transactions with Open Banking APIs (2026 Guide)</title>
      <dc:creator>John Frandsen</dc:creator>
      <pubDate>Thu, 09 Jul 2026 21:55:22 +0000</pubDate>
      <link>https://dev.to/johnfrandsen/how-to-sync-bank-transactions-with-open-banking-apis-2026-guide-57n1</link>
      <guid>https://dev.to/johnfrandsen/how-to-sync-bank-transactions-with-open-banking-apis-2026-guide-57n1</guid>
      <description>&lt;p&gt;Most open banking tutorials stop at "make a GET request." In production, you need consent flows, token refresh, pagination across thousands of transactions, deduplication, and rate-limit handling. This guide walks through the full sync pipeline — what actually breaks at 3am when your cron job fires.&lt;/p&gt;

&lt;h2&gt;
  
  
  The sync lifecycle
&lt;/h2&gt;

&lt;p&gt;A real bank-data sync has four phases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Onboard&lt;/strong&gt;: User consents to read-only access → you get a consent token&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Initial fetch&lt;/strong&gt;: Pull all historical transactions (can be thousands)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incremental sync&lt;/strong&gt;: Poll for new transactions on a schedule&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token refresh&lt;/strong&gt;: Consent expires (90 days default under PSD2) → re-authorize&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Choosing a provider
&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;Cert needed?&lt;/th&gt;
&lt;th&gt;Pricing model&lt;/th&gt;
&lt;th&gt;Bank coverage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Plaid&lt;/td&gt;
&lt;td&gt;Per-region&lt;/td&gt;
&lt;td&gt;Per-item ($0.20-0.50/mo)&lt;/td&gt;
&lt;td&gt;12,000+ globally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TrueLayer&lt;/td&gt;
&lt;td&gt;Yes (eIDAS QWAC)&lt;/td&gt;
&lt;td&gt;Volume tiers&lt;/td&gt;
&lt;td&gt;1,500+ EU/UK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tink&lt;/td&gt;
&lt;td&gt;Yes (eIDAS QWAC)&lt;/td&gt;
&lt;td&gt;Custom&lt;/td&gt;
&lt;td&gt;3,400+ EU/UK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GoCardless (Bank Account Data)&lt;/td&gt;
&lt;td&gt;No (they hold the cert)&lt;/td&gt;
&lt;td&gt;Free tier → paid&lt;/td&gt;
&lt;td&gt;1,500+ EU/UK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;open-banking.io&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;~€3/mo flat&lt;/td&gt;
&lt;td&gt;2,500+ EU/UK&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Disclosure: I maintain open-banking.io. The rest of this guide is vendor-neutral — pick whichever fits your stack.&lt;/p&gt;

&lt;p&gt;The eIDAS QWAC certificate is the hidden gatekeeper. It costs €3,000-15,000/year, requires an annual audit, and takes weeks to obtain. If you're a solo developer or small team, providers that absorb the certificate (GoCardless, open-banking.io) save you that entire process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: The consent flow
&lt;/h2&gt;

&lt;p&gt;All PSD2 Account Information Service (AIS) providers use a similar OAuth-style redirect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1. Redirect user to the bank's consent page&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAuthUrl&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;redirect_uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://yourapp.com/callback&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;scope&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;accounts transactions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;access_valid_for_days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// User clicks → bank asks "Allow YourApp to read your transactions?"&lt;/span&gt;
&lt;span class="c1"&gt;// User approves → redirects back with a code&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The consent token you receive is &lt;strong&gt;time-limited&lt;/strong&gt; (PSD2 mandates max 180 days, most default to 90). Store the expiry date — you'll need it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Fetch accounts
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getAccounts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;consentToken&lt;/span&gt;&lt;span class="p"&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;res&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;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API_BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/accounts`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&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;consentToken&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="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&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;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accounts&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// [{ id, iban, name, currency, balance }]&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: Sync transactions (the hard part)
&lt;/h2&gt;

&lt;p&gt;This is where tutorials end and production begins:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;syncTransactions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;consentToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lastSyncCursor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;allTransactions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lastSyncCursor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;hasMore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hasMore&lt;/span&gt;&lt;span class="p"&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API_BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/accounts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/transactions?cursor=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cursor&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&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;consentToken&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="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Rate limited — back off&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;retryAfter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Retry-After&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;retryAfter&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&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;page&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&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;allTransactions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transactions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="nx"&gt;cursor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;next_cursor&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;hasMore&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;has_more&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="c1"&gt;// Respect rate limits between pages&lt;/span&gt;
    &lt;span class="k"&gt;await&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;200&lt;/span&gt;&lt;span class="p"&gt;);&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="na"&gt;transactions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;allTransactions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;nextCursor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;cursor&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;
  
  
  Deduplication
&lt;/h3&gt;

&lt;p&gt;Banks will return overlapping transactions between syncs. The cursor handles most of this, but edge cases exist:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A pending transaction settles (bookingDate changes, transactionId may change)&lt;/li&gt;
&lt;li&gt;A transaction is reversed and re-booked&lt;/li&gt;
&lt;li&gt;The bank back-dates a transaction&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store a composite key: &lt;code&gt;{bankTransactionId, bookingDate, amount}&lt;/code&gt;. If the bank provides a &lt;code&gt;transactionId&lt;/code&gt; or &lt;code&gt;entryReference&lt;/code&gt;, prefer that. If not, hash the three fields above.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dedupeKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transactionId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;entryReference&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tx&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="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tx&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="s2"&gt;:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;counterpartyIban&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Token refresh
&lt;/h2&gt;

&lt;p&gt;PSD2 consent expires. You cannot silently refresh it — the user must re-authorize. Design for this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;checkConsent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;consentToken&lt;/span&gt;&lt;span class="p"&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;res&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;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API_BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/accounts`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&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;consentToken&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="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Consent expired or revoked&lt;/span&gt;
    &lt;span class="nf"&gt;notifyUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Your bank connection needs reauthorization&lt;/span&gt;&lt;span class="dl"&gt;'&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="na"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;needsReauth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&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="na"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;Send the notification early (7 days before expiry). Users who see "reconnect your bank" at the moment of need will do it. Users who see it when they're not using your app will ignore it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rate limits&lt;/strong&gt;: Most providers allow 4-15 requests/minute per consent. Batch where possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency&lt;/strong&gt;: Use the bank's &lt;code&gt;transactionId&lt;/code&gt; as your dedup key, not your own generated ID.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pagination&lt;/strong&gt;: Some banks return 100/page, others 500. Always respect the &lt;code&gt;has_more&lt;/code&gt; flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Webhooks&lt;/strong&gt;: Some providers offer transaction webhooks (push instead of poll). Use them if available — they reduce API calls by 90%+.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error handling&lt;/strong&gt;: Banks go down. The PSD2 network is 6,000+ banks — some will be unavailable. Retry with exponential backoff, but don't block the entire sync on one failing account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data freshness&lt;/strong&gt;: Transactions can appear 1-3 days after the actual payment (pending → booked). Don't display "real-time" if you're polling daily.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What breaks in production
&lt;/h2&gt;

&lt;p&gt;The three things that caused the most production incidents in my experience:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Consent expiry cascade&lt;/strong&gt;: Multiple users' consents expire on the same day (if you onboarded them in a batch). Your reauth notification queue overflows. Stagger onboarding or add jitter to expiry notifications.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Transaction ID instability&lt;/strong&gt;: Some banks change the &lt;code&gt;transactionId&lt;/code&gt; when a pending transaction settles. Your dedup fails and you get duplicates. Always store both pending and booked versions, and reconcile by matching on &lt;code&gt;amount + date + counterparty&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Rate limit headers&lt;/strong&gt;: Some providers return &lt;code&gt;X-RateLimit-Remaining&lt;/code&gt; in headers, not the response body. If you ignore headers, you'll get 429s that seem random.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;The open banking APIs are powerful, but the production details — consent lifecycle, deduplication, rate limiting — are where most integrations stall. The providers above all expose similar REST APIs, so the patterns in this guide apply regardless of which one you choose.&lt;/p&gt;

&lt;p&gt;If you're building something with bank data and want a certificate-free starting point, open-banking.io (which I maintain) offers a simple server-to-server API at around €3/month — but the architecture patterns here work with any PSD2 provider.&lt;/p&gt;

</description>
      <category>openbanking</category>
      <category>api</category>
      <category>tutorial</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
