🕒 15 minute read
Bank Data Sharing - API Guide ​ v2.1
Prerequisites ​
Before creating a Bank Data Sharing consent, ensure the following requirements are met:
Registered Application The application must be created within the Trust Framework and assigned the BDSP role as defined in Roles.
Valid Transport Certificate An active transport certificate must be issued and registered in the Trust Framework to establish secure mTLS communication.
Valid Signing Certificate An active signing certificate must be issued and registered in the Trust Framework. This certificate is used to sign request objects and client assertions.
Registration with the relevant Authorisation Server The application must be registered with the Authorisation Server of the LFI for which you intend to create a Bank Data Sharing consent.
Understanding of the FAPI Security Profile and Tokens & Assertions You should understand how request object signing, client authentication, and access token validation underpin secure API interactions.
Understanding of Consents You should understand how to create, retrieve, and manage consents, including consent states and lifecycle transitions.
API Sequence Flow ​
POST /par ​
Step 1 - Constructing Authorization Details ​
To send a /par request, first we need to generate the request JWT. We do this by first constructing authorization_details of type (urn:openfinanceuae:account-access-consent:v2.1)
authorization_details ​
| Field | Type | Description | Example |
|---|---|---|---|
type* | enum | Must be urn:openfinanceuae:account-access-consent:v2.1 | urn:openfinanceuae:account-access-consent:v2.1 |
consent* | object | Properties of the consent agreed by the User with the TPP. Described below | Described below |
subscription | object | Optional subscription to Event Notifications, to be sent to the TPP Webhook Url Described below | Described below |
consent (Required) | authorization_details.consent ​
| Field | Type | Description | Example |
|---|---|---|---|
ConsentId* | string (uuid) | Unique ID assigned by the TPP (1–128 chars) | 123e4567-e89b-12d3-a456-426614174001 |
BaseConsentId | string (uuid) | Used when renewing or modifying an existing consent | 123e4567-e89b-12d3-a456-426614174000 |
Permissions* | array<enum> | List of account access permissions being consented by the user | ReadAccountsBasic, ReadBalances |
ExpirationDateTime* | date-time | Expiry date/time (ISO 8601 with timezone, max 1 year) | 2025-11-03T15:46:00+00:00 |
FromDate | date | Start date for transaction access (ISO 8601 format) | 2023-11-03 |
ToDate | date | End date for transaction access (ISO 8601 format) | 2025-11-03 |
AccountType | array<enum> | Allowed: Retail, SME, Corporate | Retail |
AccountSubType | array<enum> | Allowed: CurrentAccount, Savings, CreditCard, Mortgage, Finance | Savings |
OpenFinanceBilling* | object | Billing parameters specified by the TPP. Described below | Described below |
OnBehalfOf | object | Provided when TPP is acting for another regulated entity Described below | Described below |
OpenFinanceBilling (Required) | authorization_details.consent.OpenFinanceBilling ​
| Field | Type | Allowed Values | Example |
|---|---|---|---|
UserType* | enum | Retail, SME, Corporate | Retail |
Purpose* | enum | AccountAggregation, RiskAssessment, TaxFiling, Onboarding, Verification, QuoteComparison, BudgetingAnalysis, FinancialAdvice, AuditReconciliation | AccountAggregation |
OnBehalfOf (Optional) | authorization_details.consent.OnBehalfOf ​
| Field | Type | Description | Example |
|---|---|---|---|
TradingName | string | Trading name if acting on behalf of another entity | Acme Ltd |
LegalName | string | Legal name of represented entity | Acme Legal Name |
IdentifierType | enum | Only Other currently supported | Other |
Identifier | string | Identifier value | 9876543210 |
subscription (Optional) | authorization_details.subscription ​
| Field | Type | Description | Example |
|---|---|---|---|
Webhook* | object | Described below | Described below |
Webhook (Required) | authorization_details.subscription.Webhook ​
| Field | Type | Description | Example |
|---|---|---|---|
Url* | string | HTTPS callback URL | https://tpp.example.com/webhook |
IsActive* | boolean | Whether webhook is active | true |
Example request ​
See an example of a valid authorization_details for urn:openfinanceuae:account-access-consent:v2.1:
"authorization_details": [
{
"type": "urn:openfinanceuae:account-access-consent:v2.1",
"consent": {
"ConsentId": "{{unique-guid}}", // Unique ID assigned by the TPP (uuid format)
"ExpirationDateTime": "2026-05-03T15:46:00+00:00", // Max 1 year from today (ISO 8601 format with timezone)
// Optional: specify start date of historic period for which data can be fetched for transactions and statements (inclusive). If not populated, data will be returned from the earliest available transaction or statement.
// "FromDate": "2024-05-03",
// Optional: specify end date of historic period for which data can be fetched for transactions and statements (inclusive). If not populated, data will be returned to the latest available transaction or statement.
// "ToDate": "2025-05-03",
"Permissions": [
"ReadAccountsBasic",
"ReadAccountsDetail",
"ReadBalances",
"ReadBeneficiariesBasic",
"ReadBeneficiariesDetail",
"ReadTransactionsBasic",
"ReadTransactionsDetail",
"ReadProduct",
"ReadScheduledPaymentsBasic",
"ReadScheduledPaymentsDetail",
"ReadDirectDebits",
"ReadStandingOrdersBasic",
"ReadStandingOrdersDetail",
"ReadStatements",
"ReadPartyUser",
"ReadPartyUserIdentity",
"ReadParty",
"ReadProductFinanceRates"
],
"OpenFinanceBilling": {
"UserType": "Retail", // Options: Retail, SME, Corporate
"Purpose": "AccountAggregation" // Purpose of data sharing (e.g., RiskAssessment, BudgetingAnalysis)
},
// Optional: to link to other ConsentId e.g. when renewing long-lived consents
// "BaseConsentId": "existing-consent-id",
// Optional: for consent on behalf of another legal entity
// "OnBehalfOf": {
// "TradingName": "Ozone",
// "LegalName": "Ozone-CBUAE",
// "IdentifierType": "Other", // Only 'Other' allowed for now
// "Identifier": "1234567890"
// },
// Optional: filter by account types
// "AccountType": [
// "Retail", // Options: Retail, SME, Corporate
// "SME"
// ],
// Optional: filter by account subtypes
// "AccountSubType": [
// "CurrentAccount", // Options: CurrentAccount, Savings, CreditCard, Mortgage, Finance
// "Savings"
// ]
},
// Optional: to receive webhook notifications from LFI
// "subscription": {
// "Webhook": {
// "Url": "https://tpp.example.com/webhook", // Must be a reachable HTTPS endpoint
// "IsActive": true
// }
// }
}
]Step 2 - Constructing the Request JWT ​
With your authorization_details ready, generate a PKCE code pair then use the buildRequestJWT() helper from the FAPI page, passing accounts openid as the scope.
import crypto from 'node:crypto'
import { generateCodeVerifier, deriveCodeChallenge } from './pkce' // from FAPI page
import { buildRequestJWT } from './request-jwt' // from FAPI page
// 1. Generate PKCE pair — store codeVerifier in your session before redirecting
const codeVerifier = generateCodeVerifier()
const codeChallenge = deriveCodeChallenge(codeVerifier)
// 2. Define the authorization_details for this consent
const authorizationDetails = [
{
type: 'urn:openfinanceuae:account-access-consent:v2.1',
consent: {
ConsentId: crypto.randomUUID(),
ExpirationDateTime: new Date(Date.now() + 364 * 24 * 60 * 60 * 1000).toISOString(),
Permissions: [
'ReadAccountsBasic',
'ReadAccountsDetail',
'ReadBalances',
'ReadTransactionsBasic',
'ReadTransactionsDetail',
],
OpenFinanceBilling: {
UserType: 'Retail',
Purpose: 'AccountAggregation',
},
},
},
]
// 3. Build and sign the Request JWT
const requestJWT = await buildRequestJWT({
scope: 'accounts openid',
codeChallenge,
authorizationDetails,
})Store the code_verifier
Save codeVerifier in your server-side session or an httpOnly cookie. You will need it in Step 7 to exchange the authorization code for tokens.
See Preparing the Request JWT for the full JWT claim reference and PKCE helpers.
Step 3 - Creating a Client Assertion ​
Every call to the Authorization Server requires a client assertion — a short-lived signed JWT that proves your application's identity in place of a client secret. Use the signJWT() helper from the FAPI Message Signing page:
import crypto from 'node:crypto'
import { signJWT } from './sign-jwt' // from FAPI Message Signing page
const CLIENT_ID = process.env.CLIENT_ID!
const ISSUER = process.env.AUTHORIZATION_SERVER_ISSUER! // from .well-known
async function buildClientAssertion(): Promise<string> {
return signJWT({
iss: CLIENT_ID,
sub: CLIENT_ID,
aud: ISSUER,
jti: crypto.randomUUID(),
})
}See Tokens & Assertions for the full claims reference and Preparing Your Client Assertion for a step-by-step walkthrough.
Step 4 - Sending the /par Request ​
With your signed Request JWT and client assertion ready, POST both to the Authorization Server's /par endpoint. The connection must use your mTLS transport certificate.
Include x-fapi-interaction-id — a UUID v4 you generate per request. The API Hub echoes it in the response, enabling end-to-end traceability. See Request Headers for the full header reference.
import crypto from 'node:crypto'
// PAR endpoint is read from .well-known/openid-configuration —
// not constructed from the issuer URL (it lives on a different host).
const PAR_ENDPOINT = discoveryDoc.pushed_authorization_request_endpoint
const parResponse = await fetch(PAR_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'x-fapi-interaction-id': crypto.randomUUID(),
},
body: new URLSearchParams({
request: requestJWT,
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: await buildClientAssertion(),
}),
// Node.js: pass an https.Agent configured with your transport cert and key
// agent: new https.Agent({ cert: transportCert, key: transportKey }),
})
const { request_uri, expires_in } = await parResponse.json()import httpx, uuid
# PAR endpoint is read from .well-known/openid-configuration —
# not constructed from the issuer URL (it lives on a different host).
par_endpoint = discovery_doc["pushed_authorization_request_endpoint"]
par_response = httpx.post(
par_endpoint,
headers={
"x-fapi-interaction-id": str(uuid.uuid4()),
},
data={
"request": request_jwt,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": build_client_assertion(),
},
# cert=("transport.crt", "transport.key"), # mTLS
)
data = par_response.json()
request_uri = data["request_uri"]
expires_in = data["expires_in"]mTLS transport certificate
You must present your transport certificate on every connection to the Authorization Server and resource APIs. In Node.js, configure an https.Agent with your PEM certificate and private key. See Certificates for how to obtain and configure your transport certificate.
The /par response contains:
| Field | Description | Example |
|---|---|---|
request_uri | A single-use reference to your pushed authorization request | urn:ietf:params:oauth:request-uri:bwc4JDpSd7 |
expires_in | Seconds until the request_uri expires — redirect the user before this window closes | 90 |
Redirecting the User to the Bank ​
Step 5 - Building the Authorization URL ​
Use the request_uri returned by /par to build the redirect URL. The authorization_endpoint is found in the LFI's .well-known/openid-configuration — not constructed from the issuer URL directly. All authorization parameters are already inside the signed Request JWT, so the only query parameters needed are client_id, response_type, scope, and request_uri.
// authorization_endpoint is discovered from the LFI's .well-known/openid-configuration
// Each LFI sets its own path — there is no fixed structure
// e.g. on the altareq1 sandbox: 'https://auth1.altareq1.sandbox.apihub.openfinance.ae/auth'
const AUTHORIZATION_ENDPOINT = discoveryDoc.authorization_endpoint
const response_type = 'code'
const authCodeUrl = `${AUTHORIZATION_ENDPOINT}?client_id=${CLIENT_ID}&response_type=${response_type}&scope=openid&request_uri=${encodeURIComponent(request_uri)}`
// Redirect the user
window.location.href = authCodeUrl
// or server-side:
// res.redirect(authCodeUrl)import urllib.parse
# authorization_endpoint from .well-known/openid-configuration
# Each LFI sets its own path — there is no fixed structure
# e.g. on the altareq1 sandbox: 'https://auth1.altareq1.sandbox.apihub.openfinance.ae/auth'
AUTHORIZATION_ENDPOINT = discovery_doc["authorization_endpoint"]
auth_code_url = (
f"{AUTHORIZATION_ENDPOINT}"
f"?client_id={CLIENT_ID}"
f"&response_type=code"
f"&scope=openid"
f"&request_uri={urllib.parse.quote(request_uri)}"
)
# redirect the user to auth_code_urlUser Experience
See User Experience for screen mockups of the Consent and Authorization pages the user sees at the bank, including an interactive example where you can edit the consent JSON and preview the resulting UI.
After redirecting, the user will:
- Authenticate with their bank
- Review the consent — accounts, permissions, and expiry — on the bank's authorization screen
- Approve or decline
Handling the Callback ​
Step 6 - Extracting the Authorization Code ​
After the user approves, the bank redirects them back to your redirect_uri. The callback includes an authorization code, the state you sent in your Request JWT, and the iss (issuer) of the Authorization Server:
https://yourapp.com/callback?code=fbe03604-baf2-4220-b7dd-05b14de19e5c&state=d2fe5e2c-77cd-4788-b0ef-7cf0fc8a3e54&iss=https://auth1.altareq1.sandbox.apihub.openfinance.aeExtract all three parameters and validate state and iss before proceeding:
const params = new URLSearchParams(window.location.search)
// or server-side: new URLSearchParams(req.url.split('?')[1])
const code = params.get('code')!
const state = params.get('state')!
const iss = params.get('iss')!
if (state !== storedState) {
throw new Error('State mismatch — possible CSRF attack. Abort the flow.')
}
if (iss !== ISSUER) {
throw new Error(`Unexpected issuer: ${iss}`)
}from urllib.parse import urlparse, parse_qs
params = parse_qs(urlparse(callback_url).query)
code = params["code"][0]
state = params["state"][0]
iss = params["iss"][0]
if state != stored_state:
raise ValueError("State mismatch — possible CSRF attack. Abort the flow.")
if iss != ISSUER:
raise ValueError(f"Unexpected issuer: {iss}")See Handling Authorization Callbacks for a full guide on security best practices including issuer verification, replay prevention, and keeping callback logic minimal.
Exchanging the Code for Tokens ​
Step 7 - POST /token (Authorization Code) ​
Exchange the authorization code for an access token and refresh token. Include the code_verifier from Step 2 — the Authorization Server will verify it against the code_challenge in your Request JWT before issuing tokens.
// Token endpoint is read from .well-known/openid-configuration —
// not constructed from the issuer URL (it lives on a different host).
const TOKEN_ENDPOINT = discoveryDoc.token_endpoint
const tokenResponse = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
code_verifier: codeVerifier, // from Step 2
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: await buildClientAssertion(),
}),
// agent: new https.Agent({ cert: transportCert, key: transportKey }),
})
const {
access_token,
refresh_token,
expires_in, // 600 — access token lasts 10 minutes
token_type, // 'Bearer'
} = await tokenResponse.json()# Token endpoint is read from .well-known/openid-configuration —
# not constructed from the issuer URL (it lives on a different host).
token_endpoint = discovery_doc["token_endpoint"]
token_response = httpx.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
"code_verifier": code_verifier, # from Step 2
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": build_client_assertion(),
},
# cert=("transport.crt", "transport.key"),
)
tokens = token_response.json()
access_token = tokens["access_token"]
refresh_token = tokens["refresh_token"]
expires_in = tokens["expires_in"] # 600 — access token lasts 10 minutesStore both tokens securely. The access token expires in 10 minutes; the refresh token remains valid for the lifetime of the consent.
Token storage
Never store tokens in localStorage. Use httpOnly cookies or a server-side session store. See Tokens & Assertions for the full token lifecycle and expiry guidance.
Calling the Account APIs ​
Step 8 - GET /accounts ​
With a valid access token, retrieve all accounts the user consented to share. Include x-fapi-interaction-id on every request, and when the customer is present also send x-fapi-customer-ip-address and x-customer-user-agent and x-fapi-auth-date if the customer has been authenticated. See Request Headers.
import crypto from 'node:crypto'
const LFI_API_BASE = process.env.LFI_API_BASE_URL! // resource server base URL from .well-known
const accountsResponse = await fetch(`${LFI_API_BASE}/open-finance/v2.1/accounts`, {
headers: {
Authorization: `Bearer ${access_token}`,
'x-fapi-interaction-id': crypto.randomUUID(),
'x-fapi-auth-date': lastCustomerAuthDate, // RFC 7231 — last time user authenticated with TPP
'x-fapi-customer-ip-address': customerIpAddress, // customer's IP address
// 'x-customer-user-agent': req.headers['user-agent'],
},
// agent: new https.Agent({ cert: transportCert, key: transportKey }),
})
const { Data: { Account: accounts } } = await accountsResponse.json()
// Store the AccountId(s) for sub-resource queries
const accountId = accounts[0].AccountIdimport uuid
accounts_response = httpx.get(
f"{LFI_API_BASE}/open-finance/v2.1/accounts",
headers={
"Authorization": f"Bearer {access_token}",
"x-fapi-interaction-id": str(uuid.uuid4()),
"x-fapi-auth-date": last_customer_auth_date, # RFC 7231 — last time user authenticated with TPP
"x-fapi-customer-ip-address": customer_ip_address, # customer's IP address
# "x-customer-user-agent": request.headers.get("user-agent"),
},
# cert=("transport.crt", "transport.key"),
)
accounts = accounts_response.json()["Data"]["Account"]
account_id = accounts[0]["AccountId"]See the GET /accounts API reference for the full response schema.
Step 9 - GET /accounts/{AccountId}/balances ​
Use a stored AccountId to fetch data from a specific account's sub-resources. Each endpoint requires the matching Read* permission in your consent. Apply the same FAPI headers as Step 8.
const balancesResponse = await fetch(
`${LFI_API_BASE}/open-finance/v2.1/accounts/${accountId}/balances`,
{
headers: {
Authorization: `Bearer ${access_token}`,
'x-fapi-interaction-id': crypto.randomUUID(),
'x-fapi-auth-date': lastCustomerAuthDate,
'x-fapi-customer-ip-address': customerIpAddress,
// 'x-customer-user-agent': req.headers['user-agent'],
},
// agent: new https.Agent({ cert: transportCert, key: transportKey }),
}
)
const { Data: { Balance } } = await balancesResponse.json()balances_response = httpx.get(
f"{LFI_API_BASE}/open-finance/v2.1/accounts/{account_id}/balances",
headers={
"Authorization": f"Bearer {access_token}",
"x-fapi-interaction-id": str(uuid.uuid4()),
"x-fapi-auth-date": last_customer_auth_date,
"x-fapi-customer-ip-address": customer_ip_address,
# "x-customer-user-agent": request.headers.get("user-agent"),
},
# cert=("transport.crt", "transport.key"),
)
balances = balances_response.json()["Data"]["Balance"]All available sub-resources and their required permissions:
| Endpoint | Required Permission | API Reference |
|---|---|---|
/accounts/{AccountId}/balances | ReadBalances | reference |
/accounts/{AccountId}/transactions | ReadTransactionsBasic | reference |
/accounts/{AccountId}/beneficiaries | ReadBeneficiariesBasic | reference |
/accounts/{AccountId}/direct-debits | ReadDirectDebits | reference |
/accounts/{AccountId}/standing-orders | ReadStandingOrdersBasic | reference |
/accounts/{AccountId}/scheduled-payments | ReadScheduledPaymentsBasic | reference |
/accounts/{AccountId}/statements | ReadStatements | reference |
/accounts/{AccountId}/parties | ReadParty | reference |
Refresh Token Flow ​
Step 10 - Refreshing the Access Token ​
Access tokens expire after 10 minutes. Track the expires_in value returned by /token and refresh proactively rather than waiting for a 401 Unauthorized. Each refresh requires a fresh client assertion.
// Reuse the TOKEN_ENDPOINT discovered in Step 7 (discoveryDoc.token_endpoint).
async function refreshAccessToken(refreshToken: string) {
const response = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: await buildClientAssertion(),
}),
// agent: new https.Agent({ cert: transportCert, key: transportKey }),
})
const { access_token, refresh_token: newRefreshToken, expires_in } = await response.json()
// Always replace both tokens — some servers rotate the refresh token on each use
return { access_token, refresh_token: newRefreshToken, expires_in }
}# Reuse the token_endpoint discovered in Step 7 (discovery_doc["token_endpoint"]).
def refresh_access_token(refresh_token: str) -> dict:
response = httpx.post(
token_endpoint,
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": build_client_assertion(),
},
# cert=("transport.crt", "transport.key"),
)
tokens = response.json()
# Always replace both tokens — some servers rotate the refresh token on each use
return {
"access_token": tokens["access_token"],
"refresh_token": tokens["refresh_token"],
"expires_in": tokens["expires_in"],
}Refresh token rotation
Always replace both access_token and refresh_token from the response. If the Authorization Server rotates refresh tokens, continuing to use the old one will return 400 invalid_grant.
The refresh token remains valid until the consent's ExpirationDateTime. Once expired, the user must go through the full authorization flow again — send a new /par request with a new ConsentId.
