Ozone

Open Finance Malaysia Developer Portal

Section 9: Accessing Account, Balance & Transaction Information

With the access token obtained from Section 8, you can now retrieve the user's account, balance, and transaction data from the Data Provider. The data is encrypted and signed using JWS/JWE for security and non-repudiation.


Overview

This section covers the four-step process for retrieving account data:

  1. Prepare the x-signature header - Create a signed JWS header for the request
  2. Call the API endpoint - Request account, balance, or transaction data
  3. Validate the JWS response - Verify the response signature
  4. Decrypt the JWE response - Decrypt the encrypted data payload

Prerequisites

Before making requests, ensure you have:

ItemWhere to FindNotes
access_tokenFrom Section 8Bearer token for authorization
account_idsFrom authorization_details in token responseThe accounts you have consent for
dc_idIssued by PayNetYour Data Consumer ID
dp_idFrom token response or Providers endpointTarget Data Provider ID
signing-key.pemFrom your certificate filesPrivate signing key for x-signature header
encryption-key.pemFrom your certificate filesPrivate encryption key for JWE decryption
kid (signing)Your JWKS endpointKey ID of your signing certificate
kid (encryption)Your JWKS endpointKey ID of your encryption certificate
mTLS transport certificate & keyFrom your certificate filesRequired for all requests
Resource Server URLFrom Providers endpointThe DP's resource server URL

Step 1: Prepare the x-signature Header

The x-signature header is a JWS that signs the request parameters to ensure non-repudiation. This prevents the OFP from creating unauthorized requests without your involvement.

x-signature Header Structure

JWS Header:

{
  "alg": "PS256",
  "kid": "{{SIGNING_KEY_ID}}"
}

JWS Body (Claims):

{
  "iss": "{{DC_ID}}",
  "sub": "{{DC_ID}}",
  "aud": ["{{DP_ID}}", "Paynet OFP"],
  "nbf": {{CURRENT_UNIX_TIMESTAMP}},
  "iat": {{CURRENT_UNIX_TIMESTAMP}},
  "jti": "550e8400-e29b-41d4-a716-446655440000",
  "url": "/v1/accounts",
  "qpm": {
    "page_size": "20"
  }
}

Claim Definitions:

ClaimRequiredTypeDescription
issRStringIssuer - Set to your DC ID
subRStringSubject - Set to your DC ID
audRArrayAudience - Array with two elements: DP ID and "Paynet OFP"
iatRIntegerIssued at - Current Unix timestamp (seconds)
nbfOIntegerNot before - Unix timestamp before which JWT cannot be used
jtiRStringJWT ID - UUIDv4 value. Must match the x-fapi-interaction-id header
urlRStringThe URL of the HTTP request (e.g., /v1/accounts)
qpmRObjectQuery Parameter Map - All query parameters as name-value pairs (not URL encoded)

Generating x-signature (Node.js)

const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const fs = require('fs');

function generateXSignature(dcId, dpId, url, queryParams, signingKey, kid) {
  const now = Math.floor(Date.now() / 1000);
  
  const header = {
    alg: 'PS256',
    kid: kid
  };
  
  const body = {
    iss: dcId,
    sub: dcId,
    aud: [dpId, 'Paynet OFP'],
    iat: now,
    nbf: now,
    jti: crypto.randomUUID(),
    url: url,
    qpm: queryParams  // name-value pairs, not URL encoded
  };
  
  const xSignature = jwt.sign(body, signingKey, {
    algorithm: 'PS256',
    header: header,
    keyid: kid
  });
  
  return xSignature;
}

// Usage
const queryParams = {
  page_size: '20'
};

const xSignature = generateXSignature(
  dcId,
  dpId,
  '/v1/accounts',
  queryParams,
  fs.readFileSync('/path/to/signing-key.pem', 'utf8'),
  'key-id-1'
);

Generating x-signature (Python)

import jwt
import json
import hashlib
import uuid
from datetime import datetime, timedelta

def generate_x_signature(dc_id, dp_id, url, query_params, signing_key, kid):
    now = int(datetime.utcnow().timestamp())
    
    header = {
        'alg': 'PS256',
        'kid': kid
    }
    
    body = {
        'iss': dc_id,
        'sub': dc_id,
        'aud': [dp_id, 'Paynet OFP'],
        'iat': now,
        'nbf': now,
        'jti': str(uuid.uuid4()),
        'url': url,
        'qpm': query_params  # name-value pairs, not URL encoded
    }
    
    x_signature = jwt.encode(
        body,
        signing_key,
        algorithm='PS256',
        headers=header
    )
    
    return x_signature

# Usage
query_params = {
    'page_size': '20'
}

x_signature = generate_x_signature(
    dc_id,
    dp_id,
    '/v1/accounts',
    query_params,
    open('/path/to/signing-key.pem', 'r').read(),
    'key-id-1'
)

Step 2: Call the API Endpoint

With the x-signature header prepared, make requests to retrieve account data. The account IDs are available from the authorization_details in the token response (Section 8).

Common Endpoints

List all accounts:

GET /v1/accounts

Get specific account:

GET /v1/accounts/{account_id}

Get account balances:

GET /v1/accounts/{account_id}/balances

Get account transactions:

GET /v1/accounts/{account_id}/transactions

Headers

Authorization: Bearer {{ACCESS_TOKEN}}
x-fapi-interaction-id: 550e8400-e29b-41d4-a716-446655440000
x-signature: {{SIGNED_JWS}}

Example cURL Request - List Accounts

curl -X GET "{{resource_server_url}}/v1/accounts" \
  --cert /path/to/transport-certificate.pem \
  --key /path/to/transport-key.pem \
  -H "Authorization: Bearer {{ACCESS_TOKEN}}" \
  -H "x-fapi-interaction-id: 550e8400-e29b-41d4-a716-446655440000" \
  -H "x-signature: {{X_SIGNATURE_JWS}}"

Example cURL Request - Get Specific Account

curl -X GET "{{resource_server_url}}/v1/accounts/{{ACCOUNT_ID}}" \
  --cert /path/to/transport-certificate.pem \
  --key /path/to/transport-key.pem \
  -H "Authorization: Bearer {{ACCESS_TOKEN}}" \
  -H "x-fapi-interaction-id: 550e8400-e29b-41d4-a716-446655440000" \
  -H "x-signature: {{X_SIGNATURE_JWS}}"

Example cURL Request - Get Account Balances

curl -X GET "{{resource_server_url}}/v1/accounts/{{ACCOUNT_ID}}/balances" \
  --cert /path/to/transport-certificate.pem \
  --key /path/to/transport-key.pem \
  -H "Authorization: Bearer {{ACCESS_TOKEN}}" \
  -H "x-fapi-interaction-id: 550e8400-e29b-41d4-a716-446655440000" \
  -H "x-signature: {{X_SIGNATURE_JWS}}"

Example cURL Request - Get Account Transactions

curl -X GET "{{resource_server_url}}/v1/accounts/{{ACCOUNT_ID}}/transactions" \
  --cert /path/to/transport-certificate.pem \
  --key /path/to/transport-key.pem \
  -H "Authorization: Bearer {{ACCESS_TOKEN}}" \
  -H "x-fapi-interaction-id: 550e8400-e29b-41d4-a716-446655440000" \
  -H "x-signature: {{X_SIGNATURE_JWS}}"

The account_ids you can request are provided in the token response's authorization_details:

{
  "authorization_details": [
    {
      "consent": {
        "accounts": [
          {
            "account_id": "550e8400-e29b-41d4-a716-446655440005",
            "account_number": "1234567890",
            "account_name": "My Savings Account"
          },
          {
            "account_id": "550e8400-e29b-41d4-a716-446655440006",
            "account_number": "0987654321",
            "account_name": "My Checking Account"
          }
        ]
      }
    }
  ]
}

Use these account_id values in your API calls.


Step 3: Validate the JWS Response

API responses are wrapped in a JWS (signed by the Data Provider) containing a JWE (encrypted payload). First, validate the JWS signature.

Response Structure

The response body is a single JWS string:

eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleTEifQ.eyJpc3MiOiJkcF8wMDAwMDEiLCJzdWIiOiJkcF8wMDAwMDEiLCJhdWQiOlsiZGNfMDAwMDAxIiwiUGF5bmV0IE9GUCJdLCJkYXRhIjoiZXlKaGJHY2lPaUpIUTBRdFNVNHRJaXdpYVcxaFoyVkZiRzluYldWdWRDSTZJbUY1Y0hSbGNpSXNJbXR3WVdkbElqcGJJbEJNUlZNaVhTSmRmUT09In0.signature...

Validation Steps

  1. Decode the JWS (don't trust yet)
  2. Extract the kid from the header
  3. Fetch DP's JWKS from their JWKS endpoint
  4. Find the public key by kid
  5. Verify the signature using PS256
  6. Extract the data field - This is the JWE to decrypt

Example: Validating JWS Response (Node.js)

const jwt = require('jsonwebtoken');
const axios = require('axios');

async function validateAndExtractJWE(jws, dpJwksUri) {
  try {
    // Step 1: Decode to get header (don't verify yet)
    const decodedJWS = jwt.decode(jws, { complete: true });
    if (!decodedJWS) {
      throw new Error('Invalid JWS structure');
    }
    
    const kid = decodedJWS.header.kid;
    
    // Step 2: Fetch DP's JWKS
    const jwksResponse = await axios.get(dpJwksUri);
    const jwks = jwksResponse.data;
    
    // Step 3: Find matching public key
    const key = jwks.keys.find(k => k.kid === kid);
    if (!key) {
      throw new Error(`Key with kid "${kid}" not found in DP JWKS`);
    }
    
    // Step 4: Verify JWS signature
    const verified = jwt.verify(jws, key, {  // Simplified; you'll need to convert JWK to PEM
      algorithms: ['PS256']
    });
    
    // Step 5: Extract JWE from data field
    const jwe = verified.data;
    
    console.log('JWS validation successful');
    return jwe;
    
  } catch (error) {
    console.error('JWS validation failed:', error.message);
    throw error;
  }
}

Step 4: Decrypt the JWE Response

The data field in the JWS body contains a JWE. Decrypt it using your private encryption key to access the account data.

JWE Structure

JWE Header:

{
  "alg": "RSA-OAEP-256",
  "enc": "A256GCM",
  "kid": "{{ENCRYPTION_KEY_ID}}"
}

The JWE body (after decryption) contains the account data as JSON.

Example: Decrypting JWE (Node.js)

const jose = require('node-jose');
const fs = require('fs');

async function decryptJWE(jwe, encryptionKeyPem) {
  try {
    // Create a keystore and add your private encryption key
    const keystore = jose.JWK.createKeyStore();
    const key = await keystore.add(
      jose.JWK.asKey(encryptionKeyPem, 'pem')
    );
    
    // Decrypt the JWE
    const result = await jose.JWE.createDecrypt(keystore).decrypt(jwe);
    
    // Extract plaintext and parse as JSON
    const plaintext = result.plaintext.toString();
    const decrypted = JSON.parse(plaintext);
    
    console.log('JWE decryption successful');
    return decrypted;
    
  } catch (error) {
    console.error('JWE decryption failed:', error.message);
    throw error;
  }
}

// Usage
const encryptedData = await decryptJWE(
  jwe,
  fs.readFileSync('/path/to/encryption-key.pem', 'utf8')
);

console.log('Account Data:', encryptedData);

Example: Decrypting JWE (Python)

from jwcrypto import jwe, jwk
import json

def decrypt_jwe(jwe_string, encryption_key_pem):
    try:
        # Load your private encryption key
        key = jwk.JWK.from_pem(encryption_key_pem.encode())
        
        # Decrypt the JWE
        encrypted_jwe = jwe.JWE()
        encrypted_jwe.deserialize(jwe_string, key=key)
        
        # Extract plaintext and parse as JSON
        plaintext = encrypted_jwe.plaintext.decode()
        decrypted = json.loads(plaintext)
        
        print('JWE decryption successful')
        return decrypted
        
    except Exception as error:
        print(f'JWE decryption failed: {str(error)}')
        raise

# Usage
with open('/path/to/encryption-key.pem', 'r') as f:
    encryption_key = f.read()

decrypted_data = decrypt_jwe(jwe, encryption_key)
print(f'Account Data: {decrypted_data}')

Response Data Structures

After decryption, the data payload contains account information in one of these formats:

Accounts Response

List of accounts returned from GET /v1/accounts or GET /v1/accounts/{account_id}:

FieldTypeDescription
account_idStringUnique immutable account identifier (36 chars)
account_numberStringThis refers to the existing account number assigned to the user's financial account to uniquely identify it within the Data Provider's core system. This is the same account number that users see in their app. For credit account types, return first six and last four digits, with the middle digits replaced by . e.g. 123456*****7890. For non-credit account types, return the full account number.
account_nameStringAccount name (e.g., "My Savings", "Business Checking")
account_holder_nameStringName of the account holder
institution_nameStringName of the financial institution
categoryStringAccount category: retail or corporate
typeStringAccount type: depository, credit, loan, or investment
subtypeStringAccount subtype: savings, current, credit_card, hire_purchase, mortgage, fixed_deposit
currencyStringISO 4217 currency code (e.g., MYR)
limitAmountCredit/loan limit if applicable
interest_rateNumberAnnual interest rate as percentage
payment_due_dateDatePayment or installment due date (if applicable)
loan_detailsObjectLoan-specific fields: loan_amount, origination_date, maturity_date
custom_dataObjectAdditional provider-specific data

Balance Response

Account balance returned from GET /v1/accounts/{account_id}/balances:

FieldTypeDescription
account_idStringUnique account identifier
current_balanceAmountCurrent available balance
available_balanceAmountAvailable balance (may differ from current for credit/loan)
credit_lines_includedBooleanWhether credit lines are included in current_balance
statement_balanceAmountBalance from last statement
statement_dateDateDate of last statement
currencyStringISO 4217 currency code
custom_dataObjectAdditional provider-specific data

Transaction Response

Array of transactions returned from GET /v1/accounts/{account_id}/transactions:

FieldTypeDescription
account_idStringAccount the transaction belongs to
transaction_dateDateTimeDate/time transaction posted (MYT timezone)
amountAmountTransaction amount with currency
credit_debit_indicatorStringcredit or debit
descriptionStringTransaction description/memo
currencyStringISO 4217 currency code
merchant_nameStringMerchant/recipient name
mccStringMerchant Category Code (4-digit code)
transfer_methodStringMethod: funds_transfer, debit_card, check, cash_withdrawal, cash_deposit
transfer_submethodStringSubmethod: duitnow_transfer, fpx, ibg, jompay, visa, etc.
is_settledBooleanWhether transaction is fully settled
recipient_referenceStringAdditional reference (e.g., DuitNow Reference ID)
custom_dataObjectAdditional provider-specific data

Amount Object

Used in balances and transactions:

FieldTypeDescription
amountStringNumeric amount as string (up to 13 digits, 5 decimals)
currencyStringISO 4217 currency code (e.g., MYR)
credit_debit_indicatorString(Balance only) credit or debit

Error Handling

API requests to retrieve account data can fail for various reasons. Handle errors appropriately based on the HTTP status code and error code returned.

HTTP 401 - Unauthorized

Error CodeDescriptionSolution
invalid_tokenAccess token is invalid or expiredRefresh the access token using the refresh_token from token response
AccessToken.InvalidScopeAccess token lacks required scopeRe-authenticate user through consent flow to obtain token with correct scope

HTTP 403 - Forbidden

Account-specific errors indicating the account is inaccessible:

Error CodeDescriptionSolution
Consent.AccountTemporarilyBlockedAccount temporarily blocked due to business ruleRetry after some time; contact bank if persists
Consent.PermanentAccountAccessFailureAccount permanently unavailableAccount no longer available; user should select different account
Consent.TransientAccountAccessFailureAccount unavailable due to transient failureRetry request; indicates temporary communications issue

HTTP 400 - Bad Request

Error CodeDescriptionSolution
Headers.MissingRequiredRequired header missing (e.g., x-fapi-interaction-id, x-signature)Verify all required headers are included in request
Headers.InvalidHTTP header present but invalidVerify header values are correctly formatted (x-signature JWS, x-fapi-interaction-id UUID)
Request.InvalidParameterInvalid query parameterVerify query parameters are valid (e.g., page_size format)
JWS.InvalidSignaturex-signature JWS signature validation failedVerify JWS is signed with correct private key and PS256 algorithm
JWS.InvalidClaimJWS claim validation failed (e.g., url doesn't match, jti mismatch)Verify url claim matches actual request path; verify jti matches x-fapi-interaction-id header
Resource.NotFoundRequested account does not existVerify account_id is valid and belongs to consented accounts

HTTP 500 - Internal Server Error

Error CodeDescriptionSolution
Service.TimeoutRequest could not be completed due to downstream timeoutRetry with exponential backoff
Service.TemporarilyUnavailableService temporarily unavailableRetry after waiting

Cryptographic Errors (Response Validation)

When validating and decrypting the JWS/JWE response:

Error CodeDescriptionSolution
JWS.InvalidSignatureResponse JWS signature invalidVerify DP's signing key in JWKS; check response not tampered
JWS.InvalidHeaderJWS header invalidVerify alg is PS256, kid references valid key
JWE.DecryptionErrorJWE decryption failedVerify encryption private key is correct; check JWE format
JWE.InvalidHeaderJWE header invalidVerify alg is RSA-OAEP-256, enc is A256GCM

End-to-End Example

// 1. Prepare signature (url should match the actual request path)
const queryParams = {};  // No query parameters for this request
const xSignature = generateXSignature(dcId, dpId, '/v1/accounts', queryParams, signingKey, kid);

// 2. Call API
const response = await axios.get(`${resourceServerUrl}/v1/accounts`, {
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'x-fapi-interaction-id': '550e8400-e29b-41d4-a716-446655440000',
    'x-signature': xSignature
  },
  httpsAgent: new https.Agent({
    cert: fs.readFileSync('/path/to/transport-certificate.pem'),
    key: fs.readFileSync('/path/to/transport-key.pem')
  })
});

const jws = response.data;  // JWS string

// 3. Validate JWS
const jwe = await validateAndExtractJWE(jws, dpJwksUri);

// 4. Decrypt JWE
const accounts = await decryptJWE(jwe, encryptionKeyPem);

console.log('User Accounts:', accounts);

Next Steps

Once you have retrieved account, balance, and transaction data:



Powered by ozoneapi

© 2026 Open Finance Malaysia Developer Portal. All rights reserved.