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.

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

The short version

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

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.

What you'll need

  • Python 3.8+ (stdlib only, no pip installs needed for the basics)
  • An API key from an AISP provider
  • A bank account in a supported EU country

I'm the maintainer of open-banking.io, 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.

Step 1: Get your API key

Sign up, grab your API key. Store it as an environment variable:

export OBI_API_KEY="***"

Enter fullscreen mode Exit fullscreen mode

Never hardcode it. If you commit it to git, rotate immediately.

Step 2: Initiate a bank connection

Your user needs to consent to sharing their data. This redirects them to their bank's login page:

import os
import json
import urllib.request

API_BASE = "https://api.open-banking.io/v1"
API_KEY=os.env...EY"]

def create_session(bank_id: str, return_url: str) -> dict:
    """Start a bank connection. Returns a consent URL for the user."""
    payload = json.dumps({
        "bank_id": bank_id,
        "return_url": return_url,
    }).encode()

    req = urllib.request.Request(
        f"{API_BASE}/sessions",
        data=payload,
        headers={
            "Authorization": f"Bearer {API_KEY}",
            "Content-Type": "application/json",
        },
        method="POST",
    )

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

# Example: connect to a Danish bank
result = create_session("nordea-dk", "https://yourapp.com/callback")
print(f"Send the user to: {result['consent_url']}")

Enter fullscreen mode Exit fullscreen mode

The user visits consent_url, logs into their bank, and approves. They're redirected back to your return_url with a session token.

Step 3: Fetch accounts and balances

Once the session is authorized:

def get_accounts(session_id: str) -> list:
    req = urllib.request.Request(
        f"{API_BASE}/sessions/{session_id}/accounts",
        headers={"Authorization": f"Bearer {API_KEY}"},
    )
    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read())["accounts"]

def get_balance(session_id: str, account_id: str) -> dict:
    req = urllib.request.Request(
        f"{API_BASE}/sessions/{session_id}/accounts/{account_id}/balance",
        headers={"Authorization": f"Bearer {API_KEY}"},
    )
    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read())

accounts = get_accounts(result["session_id"])
for acct in accounts:
    bal = get_balance(result["session_id"], acct["id"])
    print(f"{acct['name']}: {bal['amount']} {bal['currency']}")

Enter fullscreen mode Exit fullscreen mode

Step 4: Fetch transactions

def get_transactions(session_id: str, account_id: str) -> list:
    req = urllib.request.Request(
        f"{API_BASE}/sessions/{session_id}/accounts/{account_id}/transactions",
        headers={"Authorization": f"Bearer {API_KEY}"},
    )
    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read())["transactions"]

for txn in get_transactions(result["session_id"], accounts[0]["id"]):
    print(f"{txn['date']} | {txn['amount']:>8} | {txn['description'][:50]}")

Enter fullscreen mode Exit fullscreen mode

That's it. You're reading live bank data without ever touching a certificate.

Gotchas worth knowing

Consent expiry. 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.

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

Bank coverage gaps. 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.

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

Privacy model. 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.

When you should get the certificate

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.

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


Disclosure: I'm John, the maintainer of open-banking.io — 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.