Banking API Standards: Open Banking and Beyond
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
| Regulation | Scope | Key Requirements |
|---|---|---|
| PSD2 | EU/UK | Access to accounts, SCA, TPP licensing |
| UK Open Banking | UK | Standardized APIs, CMA9 mandate |
| Berlin Group | EU | NextGenPSD2 API specification |
| FDX | US/Canada | Financial 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 integrationSecurity 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
-
Compliance first: Understand PSD2/Open Banking requirements before architecture decisions
-
Security is paramount: FAPI, MTLS, and SCA are non-negotiable - implement correctly or face regulatory action
-
Consent is complex: Build robust consent management with clear customer dashboards and instant revocation
-
Legacy integration: Most complexity lies in adapting core banking systems to real-time API access
-
Test thoroughly: Use sandbox environments and conformance suites before certification
-
Audit everything: Maintain comprehensive logs - regulators will ask for them
-
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.