Skip to content

🕒 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 ​

Click to expand

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 ​

FieldTypeDescriptionExample
type*enumMust be urn:openfinanceuae:account-access-consent:v2.1urn:openfinanceuae:account-access-consent:v2.1
consent*objectProperties of the consent agreed by the User with the TPP. Described belowDescribed below
subscriptionobjectOptional subscription to Event Notifications, to be sent to the TPP Webhook Url Described belowDescribed below
FieldTypeDescriptionExample
ConsentId*string (uuid)Unique ID assigned by the TPP (1–128 chars)123e4567-e89b-12d3-a456-426614174001
BaseConsentIdstring (uuid)Used when renewing or modifying an existing consent123e4567-e89b-12d3-a456-426614174000
Permissions*array<enum>List of account access permissions being consented by the userReadAccountsBasic, ReadBalances
ExpirationDateTime*date-timeExpiry date/time (ISO 8601 with timezone, max 1 year)2025-11-03T15:46:00+00:00
FromDatedateStart date for transaction access (ISO 8601 format)2023-11-03
ToDatedateEnd date for transaction access (ISO 8601 format)2025-11-03
AccountTypearray<enum>Allowed: Retail, SME, CorporateRetail
AccountSubTypearray<enum>Allowed: CurrentAccount, Savings, CreditCard, Mortgage, FinanceSavings
OpenFinanceBilling*objectBilling parameters specified by the TPP. Described belowDescribed below
OnBehalfOfobjectProvided when TPP is acting for another regulated entity Described belowDescribed below
FieldTypeAllowed ValuesExample
UserType*enumRetail, SME, CorporateRetail
Purpose*enumAccountAggregation, RiskAssessment, TaxFiling, Onboarding, Verification, QuoteComparison, BudgetingAnalysis, FinancialAdvice, AuditReconciliationAccountAggregation
FieldTypeDescriptionExample
TradingNamestringTrading name if acting on behalf of another entityAcme Ltd
LegalNamestringLegal name of represented entityAcme Legal Name
IdentifierTypeenumOnly Other currently supportedOther
IdentifierstringIdentifier value9876543210

subscription (Optional) | authorization_details.subscription ​

FieldTypeDescriptionExample
Webhook*objectDescribed belowDescribed below

Webhook (Required) | authorization_details.subscription.Webhook ​

FieldTypeDescriptionExample
Url*stringHTTPS callback URLhttps://tpp.example.com/webhook
IsActive*booleanWhether webhook is activetrue

Example request ​

See an example of a valid authorization_details for urn:openfinanceuae:account-access-consent:v2.1:

json
"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.

typescript
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:

typescript
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.

typescript
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()
python
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:

FieldDescriptionExample
request_uriA single-use reference to your pushed authorization requesturn:ietf:params:oauth:request-uri:bwc4JDpSd7
expires_inSeconds until the request_uri expires — redirect the user before this window closes90

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.

typescript
// 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)
python
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_url

User 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:

  1. Authenticate with their bank
  2. Review the consent — accounts, permissions, and expiry — on the bank's authorization screen
  3. 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.ae

Extract all three parameters and validate state and iss before proceeding:

typescript
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}`)
}
python
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.

typescript
// 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()
python
# 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 minutes

Store 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.

typescript
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
python
import 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.

typescript
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()
python
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:

EndpointRequired PermissionAPI Reference
/accounts/{AccountId}/balancesReadBalancesreference
/accounts/{AccountId}/transactionsReadTransactionsBasicreference
/accounts/{AccountId}/beneficiariesReadBeneficiariesBasicreference
/accounts/{AccountId}/direct-debitsReadDirectDebitsreference
/accounts/{AccountId}/standing-ordersReadStandingOrdersBasicreference
/accounts/{AccountId}/scheduled-paymentsReadScheduledPaymentsBasicreference
/accounts/{AccountId}/statementsReadStatementsreference
/accounts/{AccountId}/partiesReadPartyreference

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.

typescript
// 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 }
}
python
# 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.