Skip to main content
Back to Blog
15 March 202414 min read

Banking API Standards: Open Banking and Beyond

BankingAPI DesignOpen BankingSecurity

Implementing banking APIs that comply with Open Banking standards. PSD2 requirements, security considerations, and consent management.


Banking API Standards: Open Banking and Beyond

Open Banking transformed how banks expose APIs. From my experience implementing these systems at Lloyds Banking Group, here's a comprehensive guide to building compliant, secure banking APIs.

Regulatory Landscape

Understanding the regulatory framework is essential before writing any code.

PSD2 and Open Banking

RegulationScopeKey Requirements
PSD2EU/UKAccess to accounts, SCA, TPP licensing
UK Open BankingUKStandardized APIs, CMA9 mandate
Berlin GroupEUNextGenPSD2 API specification
FDXUS/CanadaFinancial data exchange standard

Third-Party Provider Types

TPP Categories:
├── AISP (Account Information Service Provider)
│   ├── Read-only account access
│   ├── Transaction history
│   └── Balance information
├── PISP (Payment Initiation Service Provider)
│   ├── Initiate payments on behalf of user
│   ├── Requires explicit consent
│   └── SCA mandatory
└── CBPII (Card-Based Payment Instrument Issuer)
    ├── Confirmation of funds
    └── Card scheme integration

Security Architecture

Strong Customer Authentication (SCA)

SCA requires two of three authentication factors:

interface SCAFactors { knowledge: 'password' | 'pin' | 'security_question'; possession: 'mobile_device' | 'hardware_token' | 'smart_card'; inherence: 'fingerprint' | 'face_id' | 'voice_recognition'; } interface SCAExemptions { // Transactions that may be exempt from SCA lowValue: boolean; // Under £30 (limits apply) recurringPayment: boolean; // Same amount, same payee trustedBeneficiary: boolean; // Customer-whitelisted merchantInitiated: boolean; // MIT with prior consent corporatePayment: boolean; // B2B payments } function requiresSCA(transaction: Transaction): boolean { // Low value exemption with cumulative limits if (transaction.amount < 30 && transaction.cumulativeAmount < 100 && transaction.transactionCount < 5) { return false; } // Recurring payment to same payee if (transaction.isRecurring && transaction.payeeId === transaction.previousPayeeId && transaction.amount === transaction.previousAmount) { return false; } // Default: SCA required return true; }

OAuth 2.0 + FAPI Implementation

Financial-grade API (FAPI) extends OAuth for banking:

interface FAPIAuthRequest { response_type: 'code id_token'; client_id: string; redirect_uri: string; scope: 'openid accounts' | 'openid payments'; state: string; nonce: string; code_challenge: string; // PKCE required code_challenge_method: 'S256'; request: string; // Signed JWT with request details } // Creating a signed request object (JAR) function createRequestObject(params: AuthParams): string { const payload = { iss: params.clientId, aud: params.authorizationServerUrl, response_type: 'code id_token', client_id: params.clientId, redirect_uri: params.redirectUri, scope: params.scope, state: generateSecureRandom(), nonce: generateSecureRandom(), exp: Math.floor(Date.now() / 1000) + 300, iat: Math.floor(Date.now() / 1000), claims: { id_token: { openbanking_intent_id: { value: params.consentId, essential: true } } } }; return signJWT(payload, privateKey, 'PS256'); }

Certificate-Based Authentication (MTLS)

import https from 'https'; import fs from 'fs'; interface BankingClientConfig { certificatePath: string; privateKeyPath: string; caCertPath: string; baseUrl: string; } function createBankingClient(config: BankingClientConfig) { const agent = new https.Agent({ cert: fs.readFileSync(config.certificatePath), key: fs.readFileSync(config.privateKeyPath), ca: fs.readFileSync(config.caCertPath), rejectUnauthorized: true }); return { async request<T>(endpoint: string, options: RequestInit): Promise<T> { const response = await fetch(`${config.baseUrl}${endpoint}`, { ...options, // @ts-ignore - Node.js fetch agent support agent }); if (!response.ok) { throw new OpenBankingError(response.status, await response.json()); } return response.json(); } }; } // Certificate requirements const certificateRequirements = { eIDAS: { type: 'QWAC', // Qualified Website Authentication Certificate usage: 'TLS client authentication', attributes: ['organizationIdentifier', 'psd2Roles'] }, OBSeal: { type: 'QSEAL', // Qualified Electronic Seal usage: 'Message signing', purpose: 'Non-repudiation of API requests' } };

Consent Management

Consent Lifecycle

interface AccountAccessConsent { consentId: string; status: 'AwaitingAuthorisation' | 'Authorised' | 'Rejected' | 'Revoked'; creationDateTime: string; statusUpdateDateTime: string; permissions: Permission[]; expirationDateTime: string; transactionFromDateTime?: string; transactionToDateTime?: string; } type Permission = | 'ReadAccountsBasic' | 'ReadAccountsDetail' | 'ReadBalances' | 'ReadTransactionsBasic' | 'ReadTransactionsDetail' | 'ReadTransactionsCredits' | 'ReadTransactionsDebits' | 'ReadStatementsBasic' | 'ReadStatementsDetail' | 'ReadProducts' | 'ReadOffers' | 'ReadParty' | 'ReadScheduledPaymentsBasic' | 'ReadScheduledPaymentsDetail' | 'ReadBeneficiariesBasic' | 'ReadBeneficiariesDetail'; class ConsentService { async createConsent(request: ConsentRequest): Promise<AccountAccessConsent> { // Validate permissions are subset of TPP's allowed permissions this.validatePermissions(request.permissions); // Check consent doesn't exceed 90 days this.validateExpirationDate(request.expirationDateTime); const consent = await this.repository.create({ ...request, status: 'AwaitingAuthorisation', creationDateTime: new Date().toISOString() }); // Audit trail await this.auditLog.record({ action: 'CONSENT_CREATED', consentId: consent.consentId, tppId: request.tppId, permissions: request.permissions }); return consent; } async authorizeConsent( consentId: string, customerId: string, selectedAccounts: string[] ): Promise<void> { const consent = await this.repository.findById(consentId); if (consent.status !== 'AwaitingAuthorisation') { throw new ConsentError('INVALID_STATUS', 'Consent not awaiting authorisation'); } // Customer must explicitly select accounts await this.repository.update(consentId, { status: 'Authorised', authorizedAccounts: selectedAccounts, authorisationDateTime: new Date().toISOString() }); await this.auditLog.record({ action: 'CONSENT_AUTHORISED', consentId, customerId, accountCount: selectedAccounts.length }); } }

Consent Dashboard Requirements

Banks must provide customers with consent management:

interface ConsentDashboard { // List all active consents listActiveConsents(customerId: string): Promise<ConsentSummary[]>; // View consent details getConsentDetail(customerId: string, consentId: string): Promise<ConsentDetail>; // Revoke consent immediately revokeConsent(customerId: string, consentId: string): Promise<void>; // View access history getAccessHistory(customerId: string, consentId: string): Promise<AccessLog[]>; } interface ConsentSummary { consentId: string; tppName: string; tppLogo: string; permissions: string[]; // Human-readable createdDate: string; expiryDate: string; lastAccessDate: string; accessCount: number; }

API Implementation

Account Information Service (AIS)

// GET /accounts interface AccountsResponse { Data: { Account: Account[]; }; Links: { Self: string; First?: string; Prev?: string; Next?: string; Last?: string; }; Meta: { TotalPages: number; }; } interface Account { AccountId: string; Status: 'Enabled' | 'Disabled' | 'Deleted'; StatusUpdateDateTime: string; Currency: string; AccountType: 'Business' | 'Personal'; AccountSubType: 'CurrentAccount' | 'Savings' | 'CreditCard'; Description?: string; Nickname?: string; OpeningDate?: string; Account?: { SchemeName: 'UK.OBIE.SortCodeAccountNumber' | 'UK.OBIE.IBAN'; Identification: string; Name?: string; SecondaryIdentification?: string; }[]; } // GET /accounts/{AccountId}/transactions interface TransactionsResponse { Data: { Transaction: Transaction[]; }; Links: PaginationLinks; Meta: { TotalPages: number; FirstAvailableDateTime: string; LastAvailableDateTime: string; }; } interface Transaction { AccountId: string; TransactionId: string; TransactionReference?: string; Amount: { Amount: string; Currency: string; }; CreditDebitIndicator: 'Credit' | 'Debit'; Status: 'Booked' | 'Pending'; BookingDateTime: string; ValueDateTime?: string; TransactionInformation?: string; BankTransactionCode?: { Code: string; SubCode: string; }; MerchantDetails?: { MerchantName?: string; MerchantCategoryCode?: string; }; Balance?: { Amount: { Amount: string; Currency: string; }; CreditDebitIndicator: 'Credit' | 'Debit'; Type: 'ClosingAvailable' | 'ClosingBooked' | 'InterimAvailable'; }; }

Payment Initiation Service (PIS)

interface DomesticPaymentConsent { Data: { ConsentId: string; Status: PaymentConsentStatus; CreationDateTime: string; StatusUpdateDateTime: string; Initiation: { InstructionIdentification: string; EndToEndIdentification: string; LocalInstrument?: 'UK.OBIE.FPS' | 'UK.OBIE.BACS' | 'UK.OBIE.CHAPS'; InstructedAmount: { Amount: string; Currency: string; }; DebtorAccount?: { SchemeName: string; Identification: string; Name?: string; }; CreditorAccount: { SchemeName: string; Identification: string; Name: string; }; RemittanceInformation?: { Reference?: string; Unstructured?: string; }; }; Authorisation?: { AuthorisationType: 'Single' | 'Any'; CompletionDateTime?: string; }; }; Risk: PaymentRisk; } type PaymentConsentStatus = | 'AwaitingAuthorisation' | 'Authorised' | 'Consumed' | 'Rejected'; interface PaymentRisk { PaymentContextCode?: 'BillPayment' | 'EcommerceGoods' | 'PartyToParty'; MerchantCategoryCode?: string; MerchantCustomerIdentification?: string; DeliveryAddress?: { AddressLine?: string[]; StreetName?: string; PostCode?: string; TownName: string; CountrySubDivision?: string; Country: string; }; } // Execute payment after consent authorisation class PaymentService { async executeDomesticPayment( consentId: string, accessToken: string ): Promise<DomesticPaymentResponse> { const consent = await this.getConsent(consentId); if (consent.Data.Status !== 'Authorised') { throw new PaymentError('CONSENT_NOT_AUTHORISED'); } // Idempotency key prevents duplicate payments const idempotencyKey = this.generateIdempotencyKey(consent); const payment = await this.bankingClient.post('/domestic-payments', { Data: { ConsentId: consentId, Initiation: consent.Data.Initiation }, Risk: consent.Risk }, { 'x-idempotency-key': idempotencyKey, Authorization: `Bearer ${accessToken}` }); return payment; } }

Error Handling

Standardized Error Responses

interface OBErrorResponse { Code: string; Id?: string; Message: string; Errors: OBError[]; } interface OBError { ErrorCode: OBErrorCode; Message: string; Path?: string; Url?: string; } type OBErrorCode = | 'UK.OBIE.Field.Expected' | 'UK.OBIE.Field.Invalid' | 'UK.OBIE.Field.InvalidDate' | 'UK.OBIE.Field.Missing' | 'UK.OBIE.Field.Unexpected' | 'UK.OBIE.Header.Invalid' | 'UK.OBIE.Header.Missing' | 'UK.OBIE.Resource.ConsentMismatch' | 'UK.OBIE.Resource.InvalidConsentStatus' | 'UK.OBIE.Resource.InvalidFormat' | 'UK.OBIE.Resource.NotFound' | 'UK.OBIE.Rules.AfterCutOffDateTime' | 'UK.OBIE.Rules.DuplicateReference' | 'UK.OBIE.Signature.Invalid' | 'UK.OBIE.Signature.Missing' | 'UK.OBIE.UnexpectedError'; // Error handler middleware function openBankingErrorHandler( error: Error, req: Request, res: Response ): void { const errorResponse: OBErrorResponse = { Code: 'BAD_REQUEST', Id: generateCorrelationId(), Message: 'Request failed validation', Errors: [] }; if (error instanceof ValidationError) { errorResponse.Errors = error.details.map(d => ({ ErrorCode: `UK.OBIE.Field.${d.type}` as OBErrorCode, Message: d.message, Path: d.path })); res.status(400).json(errorResponse); } else if (error instanceof ConsentError) { errorResponse.Code = 'FORBIDDEN'; errorResponse.Errors = [{ ErrorCode: 'UK.OBIE.Resource.InvalidConsentStatus', Message: error.message }]; res.status(403).json(errorResponse); } else { errorResponse.Code = 'INTERNAL_SERVER_ERROR'; errorResponse.Errors = [{ ErrorCode: 'UK.OBIE.UnexpectedError', Message: 'An unexpected error occurred' }]; res.status(500).json(errorResponse); } }

Legacy System Integration

API Gateway Architecture

                    ┌─────────────────────────────────────────┐
                    │            API Gateway Layer            │
TPP Request ───────▶│  - Certificate validation               │
                    │  - Token validation                     │
                    │  - Rate limiting                        │
                    │  - Request/Response transformation      │
                    └─────────────────┬───────────────────────┘
                                      │
                    ┌─────────────────▼───────────────────────┐
                    │          Integration Layer              │
                    │  - Protocol translation                 │
                    │  - Data mapping                         │
                    │  - Orchestration                        │
                    └─────────────────┬───────────────────────┘
                                      │
              ┌───────────────────────┼───────────────────────┐
              │                       │                       │
     ┌────────▼────────┐    ┌────────▼────────┐    ┌────────▼────────┐
     │  Core Banking   │    │   Card System   │    │  Payment Rails  │
     │   (Mainframe)   │    │   (ISO 8583)    │    │   (FPS/BACS)    │
     │                 │    │                 │    │                 │
     │ - Account data  │    │ - Card txns     │    │ - Payments      │
     │ - Balances      │    │ - Auth          │    │ - Clearing      │
     └─────────────────┘    └─────────────────┘    └─────────────────┘

Caching Strategy for Core Banking

interface CacheConfig { accounts: { ttl: 300, // 5 minutes invalidateOn: ['TRANSACTION_POSTED', 'ACCOUNT_UPDATE'] }; balances: { ttl: 60, // 1 minute - frequently changing invalidateOn: ['TRANSACTION_POSTED'] }; transactions: { ttl: 3600, // 1 hour - historical data invalidateOn: ['TRANSACTION_POSTED'] }; staticData: { ttl: 86400, // 24 hours invalidateOn: ['PRODUCT_UPDATE'] }; } class CoreBankingAdapter { private cache: CacheService; private coreSystem: CoreBankingClient; async getBalance(accountId: string): Promise<Balance> { const cacheKey = `balance:${accountId}`; // Try cache first const cached = await this.cache.get<Balance>(cacheKey); if (cached) return cached; // Call core banking - may take 2-5 seconds const balance = await this.coreSystem.getBalance(accountId); // Cache with short TTL await this.cache.set(cacheKey, balance, 60); return balance; } async getTransactions( accountId: string, from: Date, to: Date ): Promise<Transaction[]> { // Historical transactions can be cached longer const cacheKey = `txns:${accountId}:${from.toISOString()}:${to.toISOString()}`; const cached = await this.cache.get<Transaction[]>(cacheKey); if (cached) return cached; const transactions = await this.coreSystem.getTransactions( accountId, from, to ); // Cache for 1 hour if not including today const ttl = to < startOfToday() ? 3600 : 300; await this.cache.set(cacheKey, transactions, ttl); return transactions; } }

Testing and Certification

Open Banking Directory Sandbox

// Test against sandbox before production const sandboxConfig = { authUrl: 'https://ob.sandbox.bank.com/oauth2/authorize', tokenUrl: 'https://ob.sandbox.bank.com/oauth2/token', resourceUrl: 'https://ob.sandbox.bank.com/open-banking/v3.1', testAccounts: [ { sortCode: '000000', accountNumber: '12345678' }, { sortCode: '000000', accountNumber: '87654321' } ] }; // Conformance test suite const conformanceTests = { ais: [ 'AIS-001: Create account access consent', 'AIS-002: Get accounts with valid consent', 'AIS-003: Get balances with ReadBalances permission', 'AIS-004: Get transactions with pagination', 'AIS-005: Reject request with expired consent', 'AIS-006: Reject request without required permission' ], pis: [ 'PIS-001: Create domestic payment consent', 'PIS-002: Execute domestic payment', 'PIS-003: Get payment status', 'PIS-004: Reject duplicate payment (idempotency)', 'PIS-005: Reject payment with mismatched consent' ] };

Audit Requirements

interface AuditLog { timestamp: string; correlationId: string; tppId: string; tppName: string; customerId?: string; consentId: string; action: AuditAction; resource: string; requestDetails: { method: string; path: string; headers: Record<string, string>; }; responseDetails: { status: number; duration: number; }; outcome: 'SUCCESS' | 'FAILURE'; failureReason?: string; } type AuditAction = | 'CONSENT_CREATED' | 'CONSENT_AUTHORISED' | 'CONSENT_REJECTED' | 'CONSENT_REVOKED' | 'ACCOUNTS_ACCESSED' | 'BALANCES_ACCESSED' | 'TRANSACTIONS_ACCESSED' | 'PAYMENT_INITIATED' | 'PAYMENT_COMPLETED'; // Audit retention requirements const auditRetention = { accessLogs: '5 years', // Regulatory requirement consentRecords: '6 years', // After consent expiry paymentRecords: '10 years', // Financial records securityEvents: '7 years' // Security incidents };

Key Takeaways

  1. Compliance first: Understand PSD2/Open Banking requirements before architecture decisions

  2. Security is paramount: FAPI, MTLS, and SCA are non-negotiable - implement correctly or face regulatory action

  3. Consent is complex: Build robust consent management with clear customer dashboards and instant revocation

  4. Legacy integration: Most complexity lies in adapting core banking systems to real-time API access

  5. Test thoroughly: Use sandbox environments and conformance suites before certification

  6. Audit everything: Maintain comprehensive logs - regulators will ask for them

  7. Plan for failure: Payment systems must handle network issues, timeouts, and idempotency correctly

Banking APIs carry significant regulatory and reputational risk. Invest in security, testing, and compliance from day one.

Share this article