Bank Data Sharing — API Guide 5 min read
Create a Bank Data Sharing consent, redirect the user to authenticate at their LFI, exchange the authorization code for tokens, and call the account APIs — an end-to-end walkthrough of the customer-present data-sharing flow.
What you need before creating a consent
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 API Hub (Authorisation Server) — the application must be registered with the API Hub (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.
End-to-end Bank Data Sharing
Bind PKCE and authorization details into a signed 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,
})
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.
Prove the application's identity to the API Hub
Every call to the API Hub (Authorisation 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.
Push the request to the API Hub
With your signed Request JWT and client assertion ready, POST both to the API Hub'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()
You must present your transport certificate on every connection to the API Hub 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 |
Validate state and issuer on the redirect
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 API Hub Authorisation Server:
https://yourapp.com/callback?code=fbe03604-baf2-4220-b7dd-05b14de19e5c&state=d2fe5e2c-77cd-4788-b0ef-7cf0fc8a3e54&iss=https://auth1.altareq1.sandbox.apihub.openfinance.ae Extract 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}`)
}
See Handling Authorization Callbacks for a full guide on security best practices including issuer verification, replay prevention, and keeping callback logic minimal.
Retrieve the consented accounts
/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].AccountId
See the GET /accounts API reference for the full response schema.
Fetch sub-resources for a specific account
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()
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 |
For /transactions and /statements, an LFI MUST make at least two years of history available. A 200 response with an empty data array therefore only means no records exist when your query range falls within that guaranteed two-year window.
If you query a period older than two years, an empty or partial result does not imply the customer had no activity then — the LFI may simply not retain data that far back (it MAY return older records, but is not required to). Do not infer absence of spend, income, or other activity for any period beyond the two-year guarantee.
The API Hub rejects a date-range query with 400Resource.InvalidFormat before it reaches the LFI when fromBookingDateTime is after toBookingDateTime, or when toBookingDateTime / toStatementDate is in the future. A fromBookingDateTime in the future is not rejected but serves no purpose — the range can never match a record. See Date Filters for the full behaviour.
Keep the session alive without re-authorisation
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 }
}
Always replace both access_token and refresh_token from the response. If the API Hub 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.
