Skip to main content

Open Banking APIs: PSD2, Open Finance Implementation

Published: February 18, 2026 Updated: May 8, 2026 Larry Qu 23 min read

Introduction

Open Banking has transformed financial services by enabling regulated third-party providers to access customer financial data and initiate payments. This article covers PSD2 compliance, API standards, and practical implementation patterns.

Key Statistics:

  • PSD2 covers 27 EU countries
  • UK Open Banking: 250+ regulated providers
  • Open Banking transactions: €6.5B annually (EU)
  • 71% of European consumers use Open Banking

Regulatory Framework

Open Banking does not exist in a regulatory vacuum — it is the product of a dense web of regional directives, national laws, and technical standards that collectively define who can access financial data, under what conditions, and with what accountability. Understanding this landscape is the first step in any implementation, because the rules that apply to you depend almost entirely on where your institution and your customers are located. A TPP operating across Europe must satisfy PSD2 and its Regulatory Technical Standards, while the same business serving UK customers must additionally comply with the Competition and Markets Authority (CMA) Order and the FCA Handbook.

The diagram below maps the major regulatory regimes that govern open banking today. The European Union leads with PSD2 and its proposed successor PSD3, which is currently in review. The UK, despite leaving the EU, has retained open banking obligations through the CMA Order and the Open Banking Implementation Entity (OBIE), which published the technical specifications most UK banks implement. In the United States, the CFPB’s Rule 1033 under Section 1033 of the Dodd-Frank Act is the closest analogue, alongside state-level laws and CCPA for consumer privacy. Beyond these, Australia’s Consumer Data Right (CDR), Canada’s advisory framework, Singapore’s MAS Open API guidelines, and Japan’s open banking rules each reflect the same core idea with locally specific twists.

The key takeaway is that there is no single “open banking” law. If you are building a cross-border product, you must implement a layered compliance matrix rather than a single standard, and you should expect the technical API specifications — covered later in this article — to diverge between jurisdictions even when the underlying consent principles are identical. This is why most serious implementations abstract the regulatory layer behind a single internal consent model, mapping regional differences into one canonical representation.

┌─────────────────────────────────────────────────────────────────┐
│                    Open Banking Regulatory Landscape                     │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  European Union                                                   │
│  ├── PSD2 (Payment Services Directive 2)                       │
│  ├── PSD3 (Proposed)                                            │
│  ├── GDPR (Data Protection)                                    │
│  └── RTS (Regulatory Technical Standards)                      │
│                                                                  │
│  United Kingdom                                                   │
│  ├── CMA Order (Competition and Markets Authority)             │
│  ├── FCA Handbook                                               │
│  └── Open Banking Implementation Entity (OBIE)                 │
│                                                                  │
│  United States                                                   │
│  ├── CFPB Rule 1033 (Consumer Data Right)                      │
│  ├── State-level banking laws                                   │
│  └── CCPA (California)                                           │
│                                                                  │
│  Other Regions                                                   │
│  ├── Australia (CDR - Consumer Data Right)                     │
│  ├── Canada (Open Banking)                                      │
│  ├── Singapore (MAS Open API)                                  │
│  └── Japan (Open Banking)                                       │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

PSD2 Requirements

Roles and Permissions

PSD2 defines three distinct roles that a third-party provider can play, and each role carries a strictly limited set of permissions. An Account Information Service Provider (AISP) can only read account data — balances, transactions, and related information — and can never initiate payments. A Payment Initiation Service Provider (PISP) can create and send payment instructions but must not access transaction history. The third role, a Card-Based Payment Instrument Issuer (CBPII), confirms the availability of funds on a card-based payment account without initiating a transfer. Enforcing these role boundaries is fundamental: a regulator will treat a TPP that exceeds its role’s permission set as a serious compliance breach.

The code below models this role-based permission system using Python enums and dataclasses. The PSD2Role enum captures the three roles, while AccountAccess describes exactly which data categories a consent covers. Rather than granting blanket access, the design forces the developer to explicitly opt in to each data category — balances, transactions, standing orders, scheduled payments, and parties — which maps naturally onto the OAuth 2.0 scopes that the authorization server will eventually hand to the ASPSP.

The PSD2Consent dataclass is the centerpiece: it bundles a consent identifier, the access permissions, a recurring indicator, a validity window, and a frequency-per-day limit. This last field matters because regulators cap how often an AISP may poll an account — typically four times a day for standard access — and any implementation that silently ignores this limit is non-compliant. The PSD2PermissionManager then translates the consent object into concrete OAuth scopes and validates the consent, checking both role-appropriate permissions and expiry. Note the validation logic that rejects an AISP requesting payment initiation and a PISP requesting transaction access, which are the two most common integration mistakes.

#!/usr/bin/env python3
"""PSD2 roles and authorization scopes."""

from enum import Enum
from dataclasses import dataclass
from typing import List, Optional

class PSD2Role(Enum):
    """PSD2 defined roles."""
    
    # Account Information Service (read-only)
    AIS = "aisp"
    
    # Payment Initiation Service (initiate payments)
    PISP = "pisp"
    
    # Card-Based Payment Instrument Issuer
    CBPII = "cbpii"

@dataclass
class AccountAccess:
    """Account access permissions."""
    
    accounts: List[str]          # Specific accounts or "all"
    balances: bool = True         # Read balances
    transactions: bool = True     # Read transactions
    standing_orders: bool = False
    scheduled_payments: bool = False
    parties: bool = False        # Account parties/owners

@dataclass
class PSD2Consent:
    """PSD2 consent for TPP access."""
    
    consent_id: str
    consent_sequence: int
    access: AccountAccess
    recurring_indicator: bool
    valid_until: str              # ISO 8601 date
    frequency_per_day: int
    combined_service_indicator: bool  # For combined AIS+PISP

class PSD2PermissionManager:
    """Manage PSD2 permissions and scopes."""
    
    # OAuth 2.0 scopes per PSD2
    SCOPES = {
        'accounts': 'ReadAccountsBasic',
        'accounts-detail': 'ReadAccountsDetail',
        'balances': 'ReadBalances',
        'transactions': 'ReadTransactionsBasic',
        'transactions-detail': 'ReadTransactionsDetail',
        'standing-orders': 'ReadStandingOrders',
        'scheduled-payments': 'ReadScheduledPayments',
        'parties': 'ReadParties',
        'payment-initiation': 'WritePayments',
    }
    
    def __init__(self, role: PSD2Role):
        self.role = role
    
    def build_scopes(self, access: AccountAccess) -> List[str]:
        """Build OAuth scopes from access permissions."""
        
        scopes = []
        
        if access.accounts:
            scopes.append(self.SCOPES['accounts'])
        
        if access.balances:
            scopes.append(self.SCOPES['balances'])
        
        if access.transactions:
            scopes.append(self.SCOPES['transactions'])
        
        if access.standing_orders:
            scopes.append(self.SCOPES['standing-orders'])
        
        if access.scheduled_payments:
            scopes.append(self.SCOPES['scheduled-payments'])
        
        if access.parties:
            scopes.append(self.SCOPES['parties'])
        
        return scopes
    
    def validate_consent(self, consent: PSD2Consent) -> tuple[bool, str]:
        """Validate PSD2 consent."""
        
        # Check role permissions
        if self.role == PSD2Role.AIS:
            if hasattr(consent, 'payment_initiation'):
                return False, "AIS cannot request payment initiation"
        
        if self.role == PSD2Role.PISP:
            if consent.access.transactions:
                return False, "PISP cannot request transaction access"
        
        # Check consent validity
        if consent.valid_until:
            from datetime import datetime
            expiry = datetime.fromisoformat(consent.valid_until.replace('Z', '+00:00'))
            if datetime.now(expiry.tzinfo) > expiry:
                return False, "Consent has expired"
        
        return True, "OK"

API Standards

Berlin Group NextGenPSD2

The Berlin Group’s NextGenPSD2 specification is the de facto standard for PSD2 API implementations across continental Europe. It defines a RESTful API surface, the JSON data structures that carry account, balance, and transaction data, and the error-handling and security conventions that every member bank follows. Its importance cannot be overstated: because hundreds of European banks implement the same interface, a TPP that integrates with one Berlin Group bank can integrate with the rest with minimal changes, dramatically reducing the cost of the many-to-many integrations that define open banking.

The Python client in the following block illustrates how a TPP talks to a Berlin Group ASPSP. Notice the design decisions encoded in the code: every request carries a Consent-ID header that ties the call to the specific consent the customer granted, and responses use transactionAmount nested objects that combine an amount with a currency, matching the ISO 20022 concepts that underpin the standard. The AccountReference dataclass is deliberately flexible — it can represent an IBAN, a BBAN, an MSISDN, or a masked card number — because the spec allows multiple account identification schemes depending on the market.

The client also demonstrates several production concerns. First, it separates parsing from transport: _parse_transaction converts raw API responses into typed Transaction objects, so downstream business logic never touches raw JSON. Second, the payment initiation method shows the shape of a SEPA credit transfer payload, including the debtor and creditor account references and the instructed amount. Third, the _request method is a seam where real-world concerns like OAuth token management, retry logic, and request signing would be plugged in — the placeholder implementation reminds you that a production client needs much more than a thin HTTP wrapper.

#!/usr/bin/env python3
"""Berlin Group NextGenPSD2 API implementation."""

from typing import Dict, List, Optional
from dataclasses import dataclass
from datetime import datetime
import hashlib
import hmac

@dataclass
class AccountReference:
    """Account identification."""
    
    iban: Optional[str] = None
    bban: Optional[str] = None
    msisdn: Optional[str] = None
    mask: Optional[str] = None  # Masked PAN for cards

@dataclass
class Transaction:
    """Transaction details."""
    
    transaction_id: str
    account_id: str
    date: str
    booking_date: str
    value_date: Optional[str]
    amount: float
    currency: str
    creditor_name: Optional[str]
    creditor_account: Optional[AccountReference]
    debtor_name: Optional[str]
    debtor_account: Optional[AccountReference]
    purpose: Optional[str]
    mandate_id: Optional[str]
    proprietary_bank_transaction_code: Optional[str]

@dataclass
class Balance:
    """Account balance."""
    
    balance_amount: float
    balance_currency: str
    balance_type: str  # "closingBooked", "openingBooked", "interimAvailable"

class NextGenPSD2API:
    """Berlin Group NextGenPSD2 API client."""
    
    BASE_URL = "https://api.bank.example.com/v1"
    
    def __init__(self, api_client_id: str, api_secret: str):
        self.client_id = api_client_id
        self.client_secret = api_secret
        self.access_token: Optional[str] = None
    
    def get_accounts(self, consent_id: str) -> List[Dict]:
        """GET /accounts - List accounts."""
        
        # Request
        # GET /v1/accounts
        # Authorization: Bearer {access_token}
        # X-Request-ID: {uuid}
        # Consent-ID: {consent_id}
        
        response = self._request(
            'GET',
            '/accounts',
            headers={'Consent-ID': consent_id}
        )
        
        return response.get('accounts', [])
    
    def get_account_balance(self, account_id: str, 
                            consent_id: str) -> List[Balance]:
        """GET /accounts/{account-id}/balances - Get balances."""
        
        response = self._request(
            'GET',
            f'/accounts/{account_id}/balances',
            headers={'Consent-ID': consent_id}
        )
        
        balances = []
        for bal in response.get('balances', []):
            balances.append(Balance(
                balance_amount=float(bal['balanceAmount']['amount']),
                balance_currency=bal['balanceAmount']['currency'],
                balance_type=bal['balanceType']
            ))
        
        return balances
    
    def get_transactions(self, account_id: str, consent_id: str,
                        date_from: Optional[str] = None,
                        date_to: Optional[str] = None) -> List[Transaction]:
        """GET /accounts/{account-id}/transactions - Get transactions."""
        
        params = {}
        if date_from:
            params['dateFrom'] = date_from
        if date_to:
            params['dateTo'] = date_to
        
        response = self._request(
            'GET',
            f'/accounts/{account_id}/transactions',
            params=params,
            headers={'Consent-ID': consent_id}
        )
        
        transactions = []
        for txn in response.get('transactions', []):
            transactions.append(self._parse_transaction(txn))
        
        return transactions
    
    def _parse_transaction(self, txn: Dict) -> Transaction:
        """Parse transaction from API response."""
        
        amount = float(txn['transactionAmount']['amount'])
        currency = txn['transactionAmount']['currency']
        
        return Transaction(
            transaction_id=txn.get('transactionId', ''),
            account_id=txn.get('accountId', ''),
            date=txn.get('date', ''),
            booking_date=txn.get('bookingDate', ''),
            value_date=txn.get('valueDate'),
            amount=amount,
            currency=currency,
            creditor_name=txn.get('creditorName'),
            creditor_account=txn.get('creditorAccount'),
            debtor_name=txn.get('debtorName'),
            debtor_account=txn.get('debtorAccount'),
            purpose=txn.get('purpose'),
            mandate_id=txn.get('mandateId'),
            proprietary_bank_transaction_code=txn.get(
                'proprietaryBankTransactionCode'
            )
        )
    
    def initiate_payment(self, consent_id: str, 
                        payment_request: Dict) -> Dict:
        """POST /payments - Initiate payment."""
        
        # Request
        # POST /v1/payments/sepa-credit-transfers
        # Authorization: Bearer {access_token}
        # X-Request-ID: {uuid}
        # Consent-ID: {consent_id}
        # Content-Type: application/json
        
        # {
        #   "instructedAmount": {
        #     "amount": "100.00",
        #     "currency": "EUR"
        #   },
        #   "debtorAccount": {
        #     "iban": "DE1234567890"
        #   },
        #   "creditorAccount": {
        #     "iban": "DE0987654321"
        #   },
        #   "creditorName": "Merchant Ltd",
        #   "remittanceInformationUnstructured": "Ref 12345"
        # }
        
        response = self._request(
            'POST',
            '/payments/sepa-credit-transfers',
            json=payment_request,
            headers={'Consent-ID': consent_id}
        )
        
        return response
    
    def _request(self, method: str, path: str, **kwargs) -> Dict:
        """Make authenticated API request."""
        
        # Implementation would use requests library
        # Includes OAuth 2.0 token management
        # Signing with OAuth 1.0a for older APIs
        
        return {"accounts": [], "transactions": []}

UK Open Banking Standards

The UK’s Open Banking standard, overseen by the Open Banking Implementation Entity (OBIE), takes a different approach from the Berlin Group. Where NextGenPSD2 is a common specification maintained by a pan-European working group, the UK standard is a prescriptive, versioned set of API specifications (currently v3.1) that participating banks must implement. It shares the same conceptual foundation — consent-driven access, OAuth 2.0 with OpenID Connect, and JSON payloads — but its resource naming, data types, and authentication details differ enough that a TPP cannot simply reuse its Berlin Group client.

The YAML configuration below shows the UK standard’s shape. Note the richer endpoint surface: accounts, balances, transactions, direct debits, standing orders, and scheduled payments are exposed as separate resources, and payments are split into domestic, international, and scheduled variants. The standard also introduces an events endpoint, which enables push-style notifications instead of pure polling — a significant operational advantage for TPPs that want to react to changes rather than repeatedly query.

The authentication section reveals a key difference from the simpler EU approach: UK Open Banking mandates mutual TLS with JWT-based request signing and relies on the OpenID Connect flow, using response_mode: jwt and signed assertions. Data types are also standardized: every account has an AccountId, AccountType (Business or Personal), and AccountSubType, and every transaction distinguishes BookingDateTime from ValueDateTime. When you build a UK Open Banking integration, expect to implement the full OIDC dance, JWT claims validation, and certificate-based mutual TLS rather than a plain client-secret exchange.

# UK Open Banking API Configuration
open_banking:
  api_version: "v3.1"
  base_url: "https://api.openbanking.org.uk"
  
  endpoints:
    accounts:
      - "/accounts"
      - "/accounts/{AccountId}"
      - "/accounts/{AccountId}/balances"
      - "/accounts/{AccountId}/transactions"
      - "/accounts/{AccountId}/direct-debits"
      - "/accounts/{AccountId}/standing-orders"
      - "/accounts/{AccountId}/scheduled-payments"
      
    payments:
      - "/domestic-payments"
      - "/domestic-scheduled-payments"
      - "/domestic-standing-orders"
      - "/international-payments"
      
    events:
      - "/events"
      - "/events/{EventSubscriptionId}"
  
  authentication:
    method: "OAuth 2.0"
    signing: "JWT"
    token_endpoint: "https://auth.openbanking.org.uk/token"
    authorization_endpoint: "https://auth.openbanking.org.uk/authorise"
  
  data_types:
    account:
      - "AccountId"
      - "Nickname"
      - "AccountType"
      - "AccountSubType"
      - "Currency"
      - "AccountNumber"
      
    transaction:
      - "TransactionId"
      - "AccountId"
      - "Amount"
      - "CreditDebitIndicator"
      - "Status"
      - "BookingDateTime"
      - "ValueDateTime"

OAuth 2.0 Implementation

Authorization Flow

Authorization in open banking is not a simple login — it is a consent-driven handshake between the TPP, the customer, the ASPSP, and the authorization server. The flow begins when the TPP builds an authorization URL and sends the customer’s browser to the bank. The bank authenticates the customer, shows them exactly which data the TPP is requesting, and on approval issues an authorization code that the TPP exchanges for tokens. The design must guarantee that the code cannot be intercepted or replayed, which is why the standards layer PKCE, state, nonce, and signed response modes on top of the basic OAuth 2.0 flow.

The Python class below implements the client side of this handshake. Study the build_authorization_url method closely: it generates a PKCE code verifier, derives a SHA-256 code challenge, and includes both in the authorization request along with a random state and nonce. The state parameter protects against CSRF by binding the callback to the session that started the flow, while the nonce binds the returned ID token to the request, preventing token replay. The response_mode: jwt parameter is an open banking requirement that returns the authorization response as a signed JWT rather than plain query parameters.

Notice also how the class manages the code verifier lifecycle. The verifier is cached when the authorization URL is built and then supplied during the token exchange, and the code comments remind you that in production this cache must live in a secure session store or Redis keyed by client_id — a plain in-memory attribute will not survive a multi-instance deployment. The exchange_code_for_token method shows the token endpoint call using the authorization code, and refresh_access_token demonstrates the refresh grant that keeps the TPP connected beyond the access token’s short lifetime. Each method isolates one concern, making the flow testable and auditable, which is exactly what a compliance-minded implementation needs.

#!/usr/bin/env python3
"""Open Banking OAuth 2.0 authorization flow."""

import uuid
import hashlib
import base64
from urllib.parse import urlencode, urljoin
from dataclasses import dataclass
from typing import Optional, Dict

@dataclass
class AuthorizationRequest:
    """OAuth 2.0 authorization request."""
    
    response_type: str = "code"  # or "code id_token" for hybrid
    client_id: str
    redirect_uri: str
    scope: str
    state: str
    nonce: str
    response_mode: str = "jwt"
    prompt: Optional[str] = None

class OpenBankingAuth:
    """Handle Open Banking OAuth 2.0 flows."""
    
    def __init__(self, issuer_url: str, client_id: str, 
                 client_secret: str, redirect_uri: str):
        self.issuer_url = issuer_url
        self.client_id = client_id
        self.client_secret = client_secret
        self.redirect_uri = redirect_uri
    
    def build_authorization_url(self, consent: Dict) -> str:
        """Build OAuth authorization URL."""
        
        # Generate PKCE challenge
        code_verifier = self._generate_code_verifier()
        code_challenge = self._generate_code_challenge(code_verifier)
        
        # Store verifier for token exchange
        self._cache_code_verifier(code_verifier)
        
        # Build authorization request
        params = {
            'response_type': 'code id_token',
            'client_id': self.client_id,
            'redirect_uri': self.redirect_uri,
            'scope': 'openid accounts payments',
            'state': str(uuid.uuid4()),
            'nonce': str(uuid.uuid4()),
            'response_mode': 'jwt',
            'code_challenge': code_challenge,
            'code_challenge_method': 'S256',
            'consent_id': consent.get('consent_id'),
        }
        
        auth_url = urljoin(self.issuer_url, 'authorize')
        return f"{auth_url}?{urlencode(params)}"
    
    def _generate_code_verifier(self) -> str:
        """Generate PKCE code verifier."""
        
        # 32-128 characters, URL-safe
        return base64.urlsafe_b64encode(
            uuid.uuid4().bytes + uuid.uuid4().bytes
        ).decode('utf-8').rstrip('=')[:128]
    
    def _generate_code_challenge(self, verifier: str) -> str:
        """Generate PKCE code challenge from verifier."""
        
        # SHA256 hash, base64url encode
        digest = hashlib.sha256(verifier.encode('utf-8')).digest()
        return base64.urlsafe_b64encode(digest).decode('utf-8').rstrip('=')
    
    def _cache_code_verifier(self, verifier: str):
        """Cache code verifier (use Redis in production)."""
        
        # In production, store in secure session/Redis
        # with client_id as key
        self._code_verifier = verifier
    
    def exchange_code_for_token(self, auth_code: str) -> Dict:
        """Exchange authorization code for tokens."""
        
        import requests
        
        token_url = urljoin(self.issuer_url, 'token')
        
        data = {
            'grant_type': 'authorization_code',
            'code': auth_code,
            'redirect_uri': self.redirect_uri,
            'client_id': self.client_id,
            'code_verifier': getattr(self, '_code_verifier', ''),
        }
        
        # Basic auth or client secret
        auth = (self.client_id, self.client_secret)
        
        response = requests.post(token_url, data=data, auth=auth)
        response.raise_for_status()
        
        tokens = response.json()
        
        return {
            'access_token': tokens.get('access_token'),
            'id_token': tokens.get('id_token'),
            'refresh_token': tokens.get('refresh_token'),
            'expires_in': tokens.get('expires_in'),
            'token_type': tokens.get('token_type'),
        }
    
    def refresh_access_token(self, refresh_token: str) -> Dict:
        """Refresh expired access token."""
        
        import requests
        
        token_url = urljoin(self.issuer_url, 'token')
        
        data = {
            'grant_type': 'refresh_token',
            'refresh_token': refresh_token,
            'client_id': self.client_id,
        }
        
        response = requests.post(token_url, data=data)
        response.raise_for_status()
        
        return response.json()

Strong Customer Authentication (SCA)

Implementation

Strong Customer Authentication (SCA) is the mechanism PSD2 uses to make payment initiation and account access dramatically harder to abuse. Under SCA, at least two independent authentication factors are required — typically something the customer knows (a PIN), something they have (a phone or card), and something they are (a fingerprint) — and the factors must be dynamically linked to the specific transaction. The rules are not absolute: the regulation defines exemptions for low-value transactions, trusted beneficiaries, and recurring payments, but each exemption must be applied with justification and is closely scrutinized by regulators and the bank’s own risk functions.

The code below models the three SCA approaches a bank can use. The redirect approach sends the customer to the bank’s own authentication page and is the most common and safest pattern, because the customer’s credentials never touch the TPP. The decoupled approach authenticates the customer through a separate device, such as a banking mobile app, while the transaction proceeds on the TPP’s site — convenient, but it requires the TPP and bank to maintain a channel for polling the SCA status. The embedded approach renders the authentication in the TPP’s own UI and is the most seamless but also the most security-sensitive, since it exposes the TPP to credential handling.

The OpenBankingSCA class captures the lifecycle of an SCA transaction. Each method constructs the appropriate request object with an scaStatus, and the authentication method is declared explicitly (for example SCA_METHOD_OTP). The verify_sca_result method is worth reading in detail: it handles the states a bank may report — started, attempted, finalised, exempted, or failed — and only returns success when the status and authentication data both check out. Finally, apply_sca_exemption enforces a whitelist of exemption types and rejects anything else, preventing an attacker (or an over-eager developer) from claiming an exemption the regulation does not allow.

#!/usr/bin/env python3
"""Strong Customer Authentication (SCA) for Open Banking."""

import hashlib
import hmac
import json
from typing import Dict, Optional
from dataclasses import dataclass
from enum import Enum

class SCAAuthenticationMethod(Enum):
    """SCA authentication methods."""
    
    SCA_METHOD_SCA_RELYING_PARTY = "SCA_RP"
    SCA_METHOD_OTP = "OTP"
    SCA_METHOD_OTHER = "OTHER"

class SCABase(Enum):
    """SCA base classes."""
    
    BASE_1 = "01"  # Single transaction
    BASE_2 = "02"  # Recurring payments (same amount)
    BASE_3 = "03"  # Recurring payments (variable amount)
    BASE_4 = "04"  # Account listing

@dataclass
class AuthenticationObject:
    """SCA authentication method."""
    
    authentication_method: str
    authentication_version: str
    authentication_data: str

@dataclass
class SCACredential:
    """SCA credential (e.g., OTP)."""
    
    credential_id: Optional[str]
    credential_data: str

class OpenBankingSCA:
    """Handle SCA for Open Banking payments."""
    
    # Authentication approaches
    APPROACH_REDIRECT = "redirect"
    APPROACH_DECOUPLED = "decoupled"
    APPROACH_EMBEDDED = "embedded"
    
    def __init__(self, aspsp_id: str):
        self.aspsp_id = aspsp_id
    
    def build_sca_authentication(self, payment: Dict, 
                                 approach: str = APPROACH_REDIRECT) -> Dict:
        """Build SCA authentication request."""
        
        # Determine authentication approach
        if approach == self.APPROACH_REDIRECT:
            return self._build_redirect_auth(payment)
        elif approach == self.APPROACH_DECOUPLED:
            return self._build_decoupled_auth(payment)
        elif approach == self.APPROACH_EMBEDDED:
            return self._build_embedded_auth(payment)
    
    def _build_redirect_auth(self, payment: Dict) -> Dict:
        """Build redirect authentication flow."""
        
        return {
            "scaApproach": self.APPROACH_REDIRECT,
            "authenticationMethods": [
                {
                    "authenticationMethod": "SCA_METHOD_SCA_RELYING_PARTY",
                    "authenticationVersion": "1.0",
                    "authenticationData": ""
                }
            ],
            "scaStatus": "started",
            "redirect": {
                "redirectURI": "https://bank.example.com/auth",
                "expirationDateTime": self._get_expiry_time(5),
            }
        }
    
    def _build_decoupled_auth(self, payment: Dict) -> Dict:
        """Build decoupled authentication (e.g., mobile app)."""
        
        return {
            "scaApproach": self.APPROACH_DECOUPLED,
            "authenticationMethods": [
                {
                    "authenticationMethod": "SCA_METHOD_OTP",
                    "authenticationVersion": "1.0",
                    "authenticationData": ""
                }
            ],
            "scaStatus": "started",
            "decoupled": {
                "issuer": "Bank Mobile App",
                "expirationDateTime": self._get_expiry_time(5),
                "info": "Please authenticate in your mobile app"
            }
        }
    
    def _build_embedded_auth(self, payment: Dict) -> Dict:
        """Build embedded authentication flow."""
        
        return {
            "scaApproach": self.APPROACH_EMBEDDED,
            "authenticationMethods": [
                {
                    "authenticationMethod": "SCA_METHOD_OTP",
                    "authenticationVersion": "1.0",
                    "authenticationData": ""
                }
            ],
            "scaStatus": "started",
            "chosenSCAMethod": {
                "authenticationMethod": "SCA_METHOD_OTP"
            }
        }
    
    def _get_expiry_time(self, minutes: int) -> str:
        """Calculate expiry timestamp."""
        
        from datetime import datetime, timedelta
        expiry = datetime.utcnow() + timedelta(minutes=minutes)
        return expiry.strftime('%Y-%m-%dT%H:%M:%SZ')
    
    def verify_sca_result(self, sca_result: Dict) -> tuple[bool, str]:
        """Verify SCA result from ASPSP."""
        
        # Check SCA status
        sca_status = sca_result.get('scaStatus')
        
        if sca_status == 'exempted':
            return True, "SCA exempted"
        
        if sca_status == 'failed':
            return False, "SCA failed"
        
        if sca_status == 'attempted':
            # Verify authentication data
            if self._verify_authentication(sca_result):
                return True, "SCA successful"
        
        if sca_status == 'finalised':
            return True, "SCA completed"
        
        return False, "Unknown SCA status"
    
    def _verify_authentication(self, sca_result: Dict) -> bool:
        """Verify authentication data."""
        
        # Verify signature/JWT
        # Check timestamp
        # Verify authentication method
        
        return True
    
    def apply_sca_exemption(self, payment: Dict, 
                           exemption_type: str) -> Dict:
        """Apply SCA exemption (limited cases)."""
        
        valid_exemptions = [
            'low_value',
            'trusted_beneficiary',
            'recurring',
            'corporate',
            'secure_corporate'
        ]
        
        if exemption_type not in valid_exemptions:
            raise ValueError(f"Invalid exemption type: {exemption_type}")
        
        return {
            "scaExemptionType": exemption_type,
            "scaStatus": "exempted"
        }

TPP Integration

Registration and Onboarding

Before a TPP can touch a single bank account, it must complete a formal registration and onboarding process that involves both national authorities and every bank it wants to connect to. In the European Union, the TPP must first register with the National Competent Authority (NCA) of its home country, obtain a legal identifier, and be entered in the public register of authorized providers. Then it must acquire two qualified certificates under the eIDAS regulation: a QWAC (Qualified Website Authentication Certificate) for TLS connections to banks, and a QSealC (Qualified e-Seal Certificate) for signing requests and client assertions. This certificate chain is what gives banks cryptographic proof that they are talking to a genuine, supervised provider.

The code below walks through this lifecycle. TPPDetails captures the entity’s legal and contact information, and register_with_nca shows the payload submitted to the national register. The setup_qsealc_certificate and setup_qwac_certificate methods highlight a critical operational concern: in production the certificates are loaded from a hardware security module (HSM) or secure key store, their chains are validated, and their validity windows are monitored so they are rotated before expiry — a certificate that lapses will silently break every API call.

Once registered at the national level, the TPP must still register separately with each bank’s developer portal. create_api_client models this step, generating per-bank client credentials and registering the redirect URIs the bank will accept for callbacks. Finally, generate_client_assertion shows how the TPP signs a JWT client assertion using its QSealC key. The assertion carries the issuer, subject, audience, a unique ID (jti), and short expiry, and the x5c header exposes the certificate chain to the authorization server — this is the modern, certificate-backed alternative to the plain client-secret authentication used in simpler OAuth deployments.

#!/usr/bin/env python3
"""Third Party Provider (TPP) registration and onboarding."""

import requests
from typing import Dict, List, Optional
from dataclasses import dataclass

@dataclass
class TPPDetails:
    """TPP registration details."""
    
    name: str
    registration_id: str
    role: str  # AISP, PISP, CBPII
    email: str
    website: str
    org_id: str  # Legal entity identifier

@dataclass
class CertificateDetails:
    """QWAC/QSealC certificate details."""
    
    certificate: str
    certificate_type: str  # QWAC or QSealC
    validity_start: str
    validity_end: str

class TPPManager:
    """Manage TPP registration and access."""
    
    def __init__(self, api_base_url: str, tpp_details: TPPDetails):
        self.api_base_url = api_base_url
        self.tpp = tpp_details
        self.certificate: Optional[CertificateDetails] = None
    
    def register_with_nca(self, national_competent_authority: str) -> Dict:
        """Register TPP with National Competent Authority."""
        
        registration_data = {
            "name": self.tpp.name,
            "registration_id": self.tpp.registration_id,
            "role": self.tpp.role,
            "email": self.tpp.email,
            "website": self.tpp.website,
            "org_id": self.tpp.org_id,
            "country": national_competent_authority,
        }
        
        # Submit to NCA register
        # POST https://register.nca.example.gov
        
        return {
            "status": "registered",
            "registration_number": "TPP-REG-001",
            "effective_date": "2026-01-01"
        }
    
    def setup_qsealc_certificate(self, cert_path: str) -> CertificateDetails:
        """Setup QSealC certificate for signing."""
        
        # In production: Load certificate from secure storage
        # Validate certificate chain
        # Check NCA registration
        
        self.certificate = CertificateDetails(
            certificate=cert_path,
            certificate_type="QSealC",
            validity_start="2026-01-01",
            validity_end="2028-01-01"
        )
        
        return self.certificate
    
    def setup_qwac_certificate(self, cert_path: str) -> CertificateDetails:
        """Setup QWAC certificate for TLS."""
        
        self.certificate = CertificateDetails(
            certificate=cert_path,
            certificate_type="QWAC",
            validity_start="2026-01-01",
            validity_end="2028-01-01"
        )
        
        return self.certificate
    
    def create_api_client(self, bank_id: str) -> Dict:
        """Create API client for specific bank."""
        
        # Register with bank's developer portal
        # Obtain client credentials
        # Configure redirect URIs
        
        return {
            "client_id": f"tpp-{bank_id}-{self.tpp.registration_id}",
            "client_secret": "generated-secret",
            "bank_id": bank_id,
            "registered_redirect_uris": [
                "https://tpp.example.com/callback"
            ],
            "status": "active"
        }
    
    def generate_client_assertion(self, client_id: str) -> str:
        """Generate JWT client assertion for token requests."""
        
        import jwt
        from datetime import datetime, timedelta
        
        # Create JWT with QSealC certificate
        assertion = jwt.encode(
            {
                "iss": client_id,
                "sub": client_id,
                "aud": "https://auth.bank.example.com",
                "jti": f"assertion-{datetime.utcnow().timestamp()}",
                "exp": datetime.utcnow() + timedelta(minutes=5),
                "iat": datetime.utcnow(),
            },
            self.certificate.certificate,
            algorithm="RS256",
            headers={
                "typ": "JWT",
                "alg": "RS256",
                "x5c": [self.certificate.certificate]  # Certificate chain
            }
        )
        
        return assertion

Error Handling

Error Codes

A well-designed error model is arguably more important in open banking than anywhere else, because TPPs are integrating with hundreds of independent banks and cannot rely on each one returning friendly error text. The industry therefore standardizes on a structured error object: a machine-readable error code, a human-readable description, and optional TPP-facing messages and additional context. When a TPP sees CONSENT_EXPIRED or TOKEN_EXPIRED, it must be able to react programmatically — by triggering a re-consent flow or refreshing the token — without parsing free-form strings.

The exception hierarchy in the code below reflects this philosophy. A base OpenBankingError carries an error code, a message, and an optional TPP message, and specialized subclasses — ConsentError, AuthenticationError, and PaymentError — group errors by domain. Each subclass defines its own named error-code constants, so business logic can reference ConsentError.CONSENT_EXPIRED instead of comparing magic strings.

The ERROR_MESSAGES dictionary centralizes the human-readable text, keeping it out of the exception classes and making it easy to localize or audit. The final function, handle_api_error, is the integration point: it inspects a raw API error response, decides which domain it belongs to by looking at the error code, and returns the matching exception type. This single mapping function means the rest of your codebase can raise and catch domain-specific exceptions while the mapping logic stays in one place, which keeps error handling consistent even when you integrate with banks that report the same failure in slightly different formats.

#!/usr/bin/env python3
"""Open Banking error handling."""

from typing import Optional, Dict
from dataclasses import dataclass

@dataclass
class OBError:
    """Open Banking error response."""
    
    error: str
    error_description: str
    tpp_message: Optional[str] = None
    additional_information: Optional[Dict] = None

class OpenBankingError(Exception):
    """Base exception for Open Banking errors."""
    
    def __init__(self, error_code: str, message: str, 
                 tpp_message: Optional[str] = None):
        self.error_code = error_code
        self.message = message
        self.tpp_message = tpp_message
        super().__init__(f"{error_code}: {message}")

class ConsentError(OpenBankingError):
    """Consent-related errors."""
    
    CONSENT_INVALID = "CONSENT_INVALID"
    CONSENT_EXPIRED = "CONSENT_EXPIRED"
    CONSENT_NOT_FOUND = "CONSENT_NOT_FOUND"
    CONSENT_ALREADY_REVOKED = "CONSENT_ALREADY_REVOKED"

class AuthenticationError(OpenBankingError):
    """Authentication errors."""
    
    INVALID_TOKEN = "INVALID_TOKEN"
    TOKEN_EXPIRED = "TOKEN_EXPIRED"
    UNAUTHORIZED = "UNAUTHORIZED"
    INVALID_SIGNATURE = "INVALID_SIGNATURE"

class PaymentError(OpenBankingError):
    """Payment processing errors."""
    
    PAYMENT_INVALID = "PAYMENT_INVALID"
    PAYMENT_FAILED = "PAYMENT_FAILED"
    INVALID_AMOUNT = "INVALID_AMOUNT"
    INVALID_CURRENCY = "INVALID_CURRENCY"
    ACCOUNT_NOT_FOUND = "ACCOUNT_NOT_FOUND"

ERROR_MESSAGES = {
    "CONSENT_INVALID": "The consent provided is invalid or malformed",
    "CONSENT_EXPIRED": "The consent has expired and is no longer valid",
    "TOKEN_EXPIRED": "The access token has expired",
    "INVALID_TOKEN": "The access token is invalid",
    "PAYMENT_FAILED": "The payment could not be processed",
    "ACCOUNT_NOT_FOUND": "The specified account was not found",
    "INVALID_SCA": "Strong Customer Authentication failed",
}

def handle_api_error(response: Dict) -> OpenBankingError:
    """Convert API error response to exception."""
    
    error_code = response.get('error', 'UNKNOWN')
    error_desc = response.get('error_description', 'Unknown error')
    
    # Map to specific exception type
    if 'CONSENT' in error_code:
        return ConsentError(error_code, error_desc)
    elif 'TOKEN' in error_code or 'UNAUTHORIZED' in error_code:
        return AuthenticationError(error_code, error_desc)
    elif 'PAYMENT' in error_code or 'ACCOUNT' in error_code:
        return PaymentError(error_code, error_desc)
    else:
        return OpenBankingError(error_code, error_desc)

Security Best Practices

Security in open banking is a defense-in-depth exercise where every layer — certificates, tokens, API transport, SCA, and monitoring — must be hardened independently, because a weakness in any single layer compromises the entire consent chain. The checklist in the following YAML distills the practices that regulators and penetration testers actually check for, and it is worth treating it as a compliance artifact rather than an optional wish list.

On the certificate front, the EU requires qualified certificates (QWAC for transport, QSealC for signatures) with private keys secured in an HSM, certificates rotated before expiry, and full chain validation. Token security emphasizes short-lived access tokens, refresh mechanisms, encrypted storage at rest, and immediate invalidation on logout — the reasoning being that a token with a short lifetime is far less dangerous when leaked than one that lasts for weeks. API security layers TLS 1.3, JWS request signing, rate limiting, and strict input validation on top.

Two areas are easy to underestimate. SCA implementation must use dynamic linking, binding the authentication to the specific transaction amount and recipient rather than authorizing a generic session, and must include documented fallback procedures when the primary channel fails. And monitoring — logging every API call, alerting on anomalies, integrating fraud detection, and producing compliance reports — is what turns a secure implementation into a defensible one. Regulators increasingly expect evidence of active monitoring, not just claims of security.

# Open Banking Security Checklist
security:
  certificate_management:
    - "Use qualified certificates (QWAC/QSealC in EU)"
    - "Rotate certificates before expiry"
    - "Secure private key storage (HSM)"
    - "Certificate chain validation"
    
  token_security:
    - "Store tokens securely (encrypted at rest)"
    - "Use short-lived access tokens"
    - "Implement token refresh"
    - "Invalidate tokens on logout"
    
  api_security:
    - "TLS 1.3 minimum"
    - "Request signing (JWS)"
    - "Rate limiting"
    - "Input validation"
    - "Output encoding"
    
  sca_implementation:
    - "Implement 2FA for all payments"
    - "Use dynamic linking"
    - "Transaction data in SCA"
    - "Fallback procedures"
    
  monitoring:
    - "Log all API calls"
    - "Alert on anomalies"
    - "Fraud detection"
    - "Compliance reporting"

External Resources


Comments

👍 Was this article helpful?