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:
- Prepare the
x-signatureheader - Create a signed JWS header for the request - Call the API endpoint - Request account, balance, or transaction data
- Validate the JWS response - Verify the response signature
- Decrypt the JWE response - Decrypt the encrypted data payload
Prerequisites
Before making requests, ensure you have:
| Item | Where to Find | Notes |
|---|---|---|
access_token | From Section 8 | Bearer token for authorization |
account_ids | From authorization_details in token response | The accounts you have consent for |
dc_id | Issued by PayNet | Your Data Consumer ID |
dp_id | From token response or Providers endpoint | Target Data Provider ID |
signing-key.pem | From your certificate files | Private signing key for x-signature header |
encryption-key.pem | From your certificate files | Private encryption key for JWE decryption |
kid (signing) | Your JWKS endpoint | Key ID of your signing certificate |
kid (encryption) | Your JWKS endpoint | Key ID of your encryption certificate |
| mTLS transport certificate & key | From your certificate files | Required for all requests |
| Resource Server URL | From Providers endpoint | The 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:
| Claim | Required | Type | Description |
|---|---|---|---|
iss | R | String | Issuer - Set to your DC ID |
sub | R | String | Subject - Set to your DC ID |
aud | R | Array | Audience - Array with two elements: DP ID and "Paynet OFP" |
iat | R | Integer | Issued at - Current Unix timestamp (seconds) |
nbf | O | Integer | Not before - Unix timestamp before which JWT cannot be used |
jti | R | String | JWT ID - UUIDv4 value. Must match the x-fapi-interaction-id header |
url | R | String | The URL of the HTTP request (e.g., /v1/accounts) |
qpm | R | Object | Query 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}}"
Important: Account IDs from Consent
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
- Decode the JWS (don't trust yet)
- Extract the
kidfrom the header - Fetch DP's JWKS from their JWKS endpoint
- Find the public key by
kid - Verify the signature using PS256
- Extract the
datafield - 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}:
| Field | Type | Description |
|---|---|---|
account_id | String | Unique immutable account identifier (36 chars) |
account_number | String | This 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_name | String | Account name (e.g., "My Savings", "Business Checking") |
account_holder_name | String | Name of the account holder |
institution_name | String | Name of the financial institution |
category | String | Account category: retail or corporate |
type | String | Account type: depository, credit, loan, or investment |
subtype | String | Account subtype: savings, current, credit_card, hire_purchase, mortgage, fixed_deposit |
currency | String | ISO 4217 currency code (e.g., MYR) |
limit | Amount | Credit/loan limit if applicable |
interest_rate | Number | Annual interest rate as percentage |
payment_due_date | Date | Payment or installment due date (if applicable) |
loan_details | Object | Loan-specific fields: loan_amount, origination_date, maturity_date |
custom_data | Object | Additional provider-specific data |
Balance Response
Account balance returned from GET /v1/accounts/{account_id}/balances:
| Field | Type | Description |
|---|---|---|
account_id | String | Unique account identifier |
current_balance | Amount | Current available balance |
available_balance | Amount | Available balance (may differ from current for credit/loan) |
credit_lines_included | Boolean | Whether credit lines are included in current_balance |
statement_balance | Amount | Balance from last statement |
statement_date | Date | Date of last statement |
currency | String | ISO 4217 currency code |
custom_data | Object | Additional provider-specific data |
Transaction Response
Array of transactions returned from GET /v1/accounts/{account_id}/transactions:
| Field | Type | Description |
|---|---|---|
account_id | String | Account the transaction belongs to |
transaction_date | DateTime | Date/time transaction posted (MYT timezone) |
amount | Amount | Transaction amount with currency |
credit_debit_indicator | String | credit or debit |
description | String | Transaction description/memo |
currency | String | ISO 4217 currency code |
merchant_name | String | Merchant/recipient name |
mcc | String | Merchant Category Code (4-digit code) |
transfer_method | String | Method: funds_transfer, debit_card, check, cash_withdrawal, cash_deposit |
transfer_submethod | String | Submethod: duitnow_transfer, fpx, ibg, jompay, visa, etc. |
is_settled | Boolean | Whether transaction is fully settled |
recipient_reference | String | Additional reference (e.g., DuitNow Reference ID) |
custom_data | Object | Additional provider-specific data |
Amount Object
Used in balances and transactions:
| Field | Type | Description |
|---|---|---|
amount | String | Numeric amount as string (up to 13 digits, 5 decimals) |
currency | String | ISO 4217 currency code (e.g., MYR) |
credit_debit_indicator | String | (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 Code | Description | Solution |
|---|---|---|
invalid_token | Access token is invalid or expired | Refresh the access token using the refresh_token from token response |
AccessToken.InvalidScope | Access token lacks required scope | Re-authenticate user through consent flow to obtain token with correct scope |
HTTP 403 - Forbidden
Account-specific errors indicating the account is inaccessible:
| Error Code | Description | Solution |
|---|---|---|
Consent.AccountTemporarilyBlocked | Account temporarily blocked due to business rule | Retry after some time; contact bank if persists |
Consent.PermanentAccountAccessFailure | Account permanently unavailable | Account no longer available; user should select different account |
Consent.TransientAccountAccessFailure | Account unavailable due to transient failure | Retry request; indicates temporary communications issue |
HTTP 400 - Bad Request
| Error Code | Description | Solution |
|---|---|---|
Headers.MissingRequired | Required header missing (e.g., x-fapi-interaction-id, x-signature) | Verify all required headers are included in request |
Headers.Invalid | HTTP header present but invalid | Verify header values are correctly formatted (x-signature JWS, x-fapi-interaction-id UUID) |
Request.InvalidParameter | Invalid query parameter | Verify query parameters are valid (e.g., page_size format) |
JWS.InvalidSignature | x-signature JWS signature validation failed | Verify JWS is signed with correct private key and PS256 algorithm |
JWS.InvalidClaim | JWS 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.NotFound | Requested account does not exist | Verify account_id is valid and belongs to consented accounts |
HTTP 500 - Internal Server Error
| Error Code | Description | Solution |
|---|---|---|
Service.Timeout | Request could not be completed due to downstream timeout | Retry with exponential backoff |
Service.TemporarilyUnavailable | Service temporarily unavailable | Retry after waiting |
Cryptographic Errors (Response Validation)
When validating and decrypting the JWS/JWE response:
| Error Code | Description | Solution |
|---|---|---|
JWS.InvalidSignature | Response JWS signature invalid | Verify DP's signing key in JWKS; check response not tampered |
JWS.InvalidHeader | JWS header invalid | Verify alg is PS256, kid references valid key |
JWE.DecryptionError | JWE decryption failed | Verify encryption private key is correct; check JWE format |
JWE.InvalidHeader | JWE header invalid | Verify 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:
- Section 8: Token Exchange - Refresh your access token when it expires
- Section 5: Discovering Data Providers - Find other data providers to request consent from
Related Documentation
- Section 8: Token Exchange - How to obtain access and refresh tokens
- Section 5: Discovering Data Providers - How to find DP resource servers and JWKS endpoints
- Section 4: Obtaining Access Tokens via Client Credentials Grant - M2M token flow for consent management
- Section 2: Discovering OFP Endpoints via Well-Known - Well-known endpoint and JWKS URI discovery
- Previous
- Token Exchange