Skip to content

🕒 15 minute read

Variable On-Demand — API Guide v2.1

A Variable On-Demand consent authorises a TPP to initiate multiple payments at variable amounts over the lifetime of the consent. The user authorises once — setting a ceiling on individual payments and optional period-based limits — and the TPP can then submit individual payments on-demand without requiring re-authorisation for each one.

Common use cases include subscription billing with variable charges, metered service payments, and TPP-managed savings top-ups.

Prerequisites

Before initiating a Variable On-Demand payment, ensure the following requirements are met:

  • Registered Application The application must be created within the Trust Framework and assigned the BSIP 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 with which you intend to initiate payments.

  • 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 Message Encryption PII (creditor name and account details) must be encrypted as a JWE before being embedded in the consent. You will need the LFI's public encryption key from their JWKS.

API Sequence Flow

Click to expand

POST /par

Step 1 - Encrypting PII

The consent.PersonalIdentifiableInformation property in the authorization_details carries sensitive payment data — creditor account details, debtor information, and risk indicators. Because consents are stored centrally at Nebras, this data is encrypted end-to-end so that no intermediate party can read it.

The schema defines PersonalIdentifiableInformation as a oneOf with three variants:

VariantFormNotes
Domestic Payment PII Schema ObjectobjectUnencrypted form — shows the PII structure for domestic payments. For reference only.
International Payment PII Schema ObjectobjectUnencrypted form — shows the PII structure for international payments. For reference only.
Encrypted PII Object (AEJWEPaymentPII)stringCompact JWE string. MUST be used when invoking the PAR operation.

Domestic Payment PII Schema Object must be strictly followed

The object you encrypt MUST conform exactly to the Domestic Payment PII Schema Object. Field names, nesting, and data types are validated by the LFI after decryption — any deviation will result in payment rejection. Do not add undocumented fields or omit required ones.

See Personal Identifiable Information for the complete field reference, required vs optional fields, and creditor models for each domestic payment type.

Creditor array — choosing a beneficiary model

Initiation.Creditor determines the beneficiary model for all payments made under this consent:

EntriesModelEffect
OmittedOpen beneficiaryNo creditor fixed at consent time — each POST /payments supplies its own creditor
1 entrySingle beneficiaryConsent is bound to that one recipient — every payment must go to that account
2–10 entriesMultiple beneficiariesConsent authorises any one of the listed accounts — each payment specifies which one

See Creditor for the field schema, matching rules, and validation requirements.

The PII object is serialized to JSON, signed as a JWS using your signing key, and then encrypted as a JWE using the LFI's public encryption key — producing the AEJWEPaymentPII compact string embedded as PersonalIdentifiableInformation in the consent.

Encrypting the PII

Build the PII object according to the schema, then encrypt it as a JWE using the LFI's public encryption key:

typescript
import { SignJWT, importJWK, CompactEncrypt } from 'jose'

/**
 * Sign PII as a JWT and encrypt it as a JWE using the LFI's public encryption key.
 * Fetch the LFI's JWKS URI from their .well-known/openid-configuration.
 */
async function encryptPII(pii: object, jwksUri: string, signingKey: CryptoKey, signingKeyId: string): Promise<string> {
  // 1. Sign the PII as a JWT
  const signedPII = await new SignJWT(pii as Record<string, unknown>)
    .setProtectedHeader({ alg: 'PS256', kid: signingKeyId })
    .sign(signingKey)

  // 2. Fetch the LFI's encryption key
  const { keys } = await fetch(jwksUri).then(r => r.json())
  const encKeyJwk = keys.find((k: { use: string }) => k.use === 'enc')
  if (!encKeyJwk) throw new Error('No encryption key (use: enc) found in JWKS')

  const encKey = await importJWK(encKeyJwk, 'RSA-OAEP-256')

  // 3. Encrypt the signed JWT
  return new CompactEncrypt(new TextEncoder().encode(signedPII))
    .setProtectedHeader({
      alg: 'RSA-OAEP-256',
      enc: 'A256GCM',
      kid: encKeyJwk.kid,
    })
    .encrypt(encKey)
}

const pii = {
   "Initiation": {
     "DebtorAccount": {
       "SchemeName": "IBAN",
       "Identification": "AE070331234567890123456",
       "Name": {
         "en": "Mohammed Al Rashidi",
       }
     },
    // Creditor array is optional — omit for open beneficiary, or supply 1–10 entries
    "Creditor": [
      {
        "Creditor": {
          "Name": "Ivan England"
        },
        "CreditorAccount": {
          "SchemeName": "IBAN",
          "Identification": "AE070331234567890123456",
          "Name": {
            "en": "Ivan David England"
          }
        }
      }
    ]
  }
}

const encryptedPII = await encryptPII(pii, LFI_JWKS_URI, signingKey, SIGNING_KEY_ID)
// encryptedPII is a compact JWE string — embed it in authorization_details below
python
import json
import requests
from jose import jwe

def encrypt_pii(pii: dict, jwks_uri: str) -> str:
    keys = requests.get(jwks_uri).json()["keys"]
    enc_key = next((k for k in keys if k.get("use") == "enc"), None)
    if not enc_key:
        raise ValueError("No encryption key (use: enc) found in JWKS")

    return jwe.encrypt(
        json.dumps(pii).encode(),
        enc_key,
        algorithm="RSA-OAEP-256",
        encryption="A256GCM",
    ).decode()

pii = {
  "Initiation": {
     "DebtorAccount": {
       "SchemeName": "IBAN",
       "Identification": "AE070331234567890123456",
       "Name": {
         "en": "Mohammed Al Rashidi",
       }
     },
    # Creditor array is optional — omit for open beneficiary, or supply 1–10 entries
    "Creditor": [
      {
        "Creditor": {
          "Name": "Ivan England"
        },
        "CreditorAccount": {
          "SchemeName": "IBAN",
          "Identification": "AE070331234567890123456",
          "Name": {
            "en": "Ivan David England"
          }
        }
      }
    ]
  },
}

encrypted_pii = encrypt_pii(pii, LFI_JWKS_URI)
# encrypted_pii is a compact JWE string — embed it in authorization_details below

See Message Encryption for details on fetching the LFI's JWKS and selecting the correct encryption key.

Step 2 - Constructing Authorization Details

With the encrypted PII ready, construct the authorization_details of type urn:openfinanceuae:service-initiation-consent:v2.1. The critical difference from Single Instant Payment is that ControlParameters uses MultiPayment with Type: "VariableOnDemand" — no fixed amount is set at consent time. Instead, limits are defined via cumulative caps and periodic controls.

authorization_details

FieldTypeDescriptionExample
type*enumMust be urn:openfinanceuae:service-initiation-consent:v2.1urn:openfinanceuae:service-initiation-consent:v2.1
consent*objectConsent properties agreed by the User with the TPP. Described below
subscriptionobjectOptional subscription to Event Notifications via Webhook. Described below
FieldTypeDescriptionExample
ConsentId*string (uuid)Unique ID assigned by the TPP (1–128 chars)b8f42378-10ac-46a1-8d20-4e020484216d
IsSingleAuthorization*booleanWhether the payment requires only one authorizing partytrue
ExpirationDateTime*date-timeConsent expiry (ISO 8601 with timezone, max 1 year)2027-03-02T00:00:00+00:00
AuthorizationExpirationDateTimedate-timeDeadline by which all authorizers must have acted (multi-authorization only). SHOULD be set when IsSingleAuthorization is false; SHOULD NOT be set when IsSingleAuthorization is true. MUST NOT be after ExpirationDateTime.2026-03-03T10:00:00+00:00
BaseConsentIdstring (uuid)Links to prior consent if renewing — see Base Consent ID
Permissionsarray<enum>Optional access permissions granted alongside the payment consentReadAccountsBasic, ReadBalances
ControlParameters*objectPayment controls — see below
PersonalIdentifiableInformation*string (JWE)Encrypted creditor and risk data — the encryptedPII string from Step 1eyJhbGci...
PaymentPurposeCode*string (3 chars)AANI payment purpose codeACM
DebtorReferencestringReference shown on the debtor's statementSubscription
CreditorReferencestringReference shown on the creditor's statementSubscription

ControlParameters — Variable On-Demand

ControlParameters.ConsentSchedule.MultiPayment carries the control definition. Set PeriodicSchedule.Type to "VariableOnDemand". The amount is not fixed at consent time — each POST /payments call specifies its own amount, subject to the controls below.

Cumulative Control Parameters — apply across the entire consent lifetime:

FieldRequiredDescriptionExample
MaximumCumulativeValueOfPayments.AmountNoMaximum total value of all payments over the consent lifetime10000.00
MaximumCumulativeValueOfPayments.CurrencyNoISO 4217 currency codeAED
MaximumCumulativeNumberOfPaymentsNoMaximum total number of payments over the consent lifetime50

How PeriodType governs the periodic controls

PeriodicSchedule.PeriodType sets the unit for the periodic controls — change it from "Month" to "Week", "Day", or "Year" and the per-period caps (MaximumCumulativeValueOfPaymentsPerPeriod, MaximumCumulativeNumberOfPaymentsPerPeriod) rescope to that unit. For example, with PeriodType: "Month" and MaximumCumulativeNumberOfPaymentsPerPeriod: 3, the TPP may submit at most 3 payments in any calendar month under this consent.

PeriodType does not schedule payments — the TPP still triggers each POST /payments on-demand. This differs from the periodic variants (FixedPeriodicSchedule, VariablePeriodicSchedule) and the defined variants (FixedDefinedSchedule, VariableDefinedSchedule), where the schedule itself governs when payments may be submitted.

Periodic Control Parameters — apply per recurring period, defined inside PeriodicSchedule:

FieldRequiredDescriptionExample
PeriodicSchedule.PeriodTypeYesThe period length: Day, Week, Month, or YearMonth
PeriodicSchedule.PeriodStartDateYesThe date from which the period starts. For Day and Year, this is when payments may begin. Must not be in the past (today is accepted) and must be before ExpirationDateTime2027-01-01
PeriodicSchedule.Controls.MaximumIndividualAmount.AmountAt least one of threeMaximum amount permitted per individual payment500.00
PeriodicSchedule.Controls.MaximumIndividualAmount.CurrencyAt least one of threeISO 4217 currency codeAED
PeriodicSchedule.Controls.MaximumCumulativeValueOfPaymentsPerPeriod.AmountNoMaximum cumulative value of payments within the period. Must be greater than MaximumIndividualAmount when both are provided2000.00
PeriodicSchedule.Controls.MaximumCumulativeValueOfPaymentsPerPeriod.CurrencyNoISO 4217 currency codeAED
PeriodicSchedule.Controls.MaximumCumulativeNumberOfPaymentsPerPeriodNoMaximum number of payments within the period5

At least one periodic control must be provided

At least one of MaximumIndividualAmount, MaximumCumulativeValueOfPaymentsPerPeriod, or MaximumCumulativeNumberOfPaymentsPerPeriod must be present inside PeriodicSchedule.Controls. The cumulative and lifetime caps are independent of each other.

Example request

json
"authorization_details": [
  {
    "type": "urn:openfinanceuae:service-initiation-consent:v2.1",
    "consent": {
      "ConsentId": "{{unique-guid}}",
      "IsSingleAuthorization": true,
      "ExpirationDateTime": "2027-03-02T00:00:00+00:00",

      // Multi-authorization only: deadline for all authorizers to act.
      // SHOULD NOT be set when IsSingleAuthorization is true.
      // "AuthorizationExpirationDateTime": "2026-03-03T10:00:00+00:00",

      "Permissions": [
        "ReadAccountsBasic",
        "ReadAccountsDetail",
        "ReadBalances"
      ],

      "ControlParameters": {
        "ConsentSchedule": {
          "MultiPayment": {
            // Optional consent-lifetime cumulative caps:
            // "MaximumCumulativeValueOfPayments": { "Amount": "10000.00", "Currency": "AED" },
            // "MaximumCumulativeNumberOfPayments": 50,

            "PeriodicSchedule": {
              "Type": "VariableOnDemand",
              "PeriodType": "Month",
              "PeriodStartDate": "2027-01-01",
              "Controls": {
                // At least ONE of the following three must be provided:
                "MaximumIndividualAmount": { "Amount": "500.00", "Currency": "AED" }
                // Optional per-period caps:
                // "MaximumCumulativeValueOfPaymentsPerPeriod": { "Amount": "2000.00", "Currency": "AED" },
                // "MaximumCumulativeNumberOfPaymentsPerPeriod": 5
              }
            }
          }
        }
      },

      // Encrypted PII from Step 1
      "PersonalIdentifiableInformation": "{{encryptedPII}}",

      "PaymentPurposeCode": "ACM",
      "DebtorReference": "Subscription",
      "CreditorReference": "Subscription"
    }
  }
]

Step 3 - Constructing the Request JWT

With your authorization_details ready, generate a PKCE code pair then use the buildRequestJWT() helper, passing payments openid as the scope.

Scope change required when using Permissions

If your consent includes ReadAccountsBasic, ReadAccountsDetail, or ReadBalances, you must change the scope to accounts payments openid. Without the accounts scope the issued token will not grant access to the account endpoints. You will also need the BDSP role. See Account Permissions in a Payment Consent.

typescript
import crypto from 'node:crypto'
import { generateCodeVerifier, deriveCodeChallenge } from './pkce'
import { buildRequestJWT } from './request-jwt'

const codeVerifier  = generateCodeVerifier()
const codeChallenge = deriveCodeChallenge(codeVerifier)

const authorizationDetails = [
  {
    type: 'urn:openfinanceuae:service-initiation-consent:v2.1',
    consent: {
      ConsentId: crypto.randomUUID(),
      IsSingleAuthorization: true,
      ExpirationDateTime: new Date(Date.now() + 364 * 24 * 60 * 60 * 1000).toISOString(),
      Permissions: ['ReadAccountsBasic', 'ReadAccountsDetail', 'ReadBalances'],
      ControlParameters: {
        ConsentSchedule: {
          MultiPayment: {
            PeriodicSchedule: {
              Type: 'VariableOnDemand',
              PeriodType: 'Month',
              PeriodStartDate: '2027-01-01',
              Controls: {
                MaximumIndividualAmount: { Amount: '500.00', Currency: 'AED' },
                // MaximumCumulativeValueOfPaymentsPerPeriod: { Amount: '2000.00', Currency: 'AED' },
                // MaximumCumulativeNumberOfPaymentsPerPeriod: 5,
              },
            },
            // MaximumCumulativeValueOfPayments: { Amount: '10000.00', Currency: 'AED' },
            // MaximumCumulativeNumberOfPayments: 50,
          },
        },
      },
      PersonalIdentifiableInformation: encryptedPII,  // from Step 1
      PaymentPurposeCode: 'ACM',
      DebtorReference: 'Subscription',
      CreditorReference: 'Subscription',
    },
  },
]

const requestJWT = await buildRequestJWT({
  scope: 'payments openid',
  codeChallenge,
  authorizationDetails,
})
python
import uuid
from datetime import datetime, timezone, timedelta
from pkce import generate_code_verifier, derive_code_challenge
from request_jwt import build_request_jwt

code_verifier  = generate_code_verifier()
code_challenge = derive_code_challenge(code_verifier)

authorization_details = [
    {
        "type": "urn:openfinanceuae:service-initiation-consent:v2.1",
        "consent": {
            "ConsentId": str(uuid.uuid4()),
            "IsSingleAuthorization": True,
            "ExpirationDateTime": (datetime.now(timezone.utc) + timedelta(days=364)).isoformat(),
            "Permissions": ["ReadAccountsBasic", "ReadAccountsDetail", "ReadBalances"],
            "ControlParameters": {
                "ConsentSchedule": {
                    "MultiPayment": {
                        "PeriodicSchedule": {
                            "Type": "VariableOnDemand",
                            "PeriodType": "Month",
                            "PeriodStartDate": "2027-01-01",
                            "Controls": {
                                "MaximumIndividualAmount": {"Amount": "500.00", "Currency": "AED"},
                                # "MaximumCumulativeValueOfPaymentsPerPeriod": {"Amount": "2000.00", "Currency": "AED"},
                                # "MaximumCumulativeNumberOfPaymentsPerPeriod": 5,
                            },
                        },
                        # "MaximumCumulativeValueOfPayments": {"Amount": "10000.00", "Currency": "AED"},
                        # "MaximumCumulativeNumberOfPayments": 50,
                    }
                }
            },
            "PersonalIdentifiableInformation": encrypted_pii,  # from Step 1
            "PaymentPurposeCode": "ACM",
            "DebtorReference": "Subscription",
            "CreditorReference": "Subscription",
        },
    }
]

request_jwt = build_request_jwt(
    scope="payments openid",
    code_challenge=code_challenge,
    authorization_details=authorization_details,
)

Store the code_verifier

Save codeVerifier in your server-side session or an httpOnly cookie — you will need it in Step 8 to exchange the authorization code for tokens.

See Preparing the Request JWT for the full JWT claim reference and PKCE helpers.

Step 4 - Creating a Client Assertion

Use the signJWT() helper to build a client assertion proving your application's identity:

typescript
import crypto from 'node:crypto'
import { signJWT } from './sign-jwt'

const CLIENT_ID = process.env.CLIENT_ID!
const ISSUER    = process.env.AUTHORIZATION_SERVER_ISSUER!

async function buildClientAssertion(): Promise<string> {
  return signJWT({
    iss: CLIENT_ID,
    sub: CLIENT_ID,
    aud: ISSUER,
    jti: crypto.randomUUID(),
  })
}
python
import os
import uuid
from sign_jwt import sign_jwt

CLIENT_ID = os.environ["CLIENT_ID"]
ISSUER    = os.environ["AUTHORIZATION_SERVER_ISSUER"]

def build_client_assertion() -> str:
    return sign_jwt({
        "iss": CLIENT_ID,
        "sub": CLIENT_ID,
        "aud": ISSUER,
        "jti": str(uuid.uuid4()),
    })

See Client Assertion for the full claims reference.

Step 5 - Sending the /par Request

Include x-fapi-interaction-id on the request — the API Hub echoes it in the response for end-to-end traceability. See Request Headers.

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(),
  }),
  // 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"),
)

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. See Certificates.

FieldDescriptionExample
request_uriSingle-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 6 - Building the Authorization URL

The authorization_endpoint is found in the LFI's .well-known/openid-configuration — not constructed from the issuer URL directly.

typescript
// 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'
const AUTHORIZATION_ENDPOINT = discoveryDoc.authorization_endpoint

const authCodeUrl = `${AUTHORIZATION_ENDPOINT}?client_id=${CLIENT_ID}&response_type=code&scope=openid&request_uri=${encodeURIComponent(request_uri)}`

window.location.href = authCodeUrl
// or server-side: res.redirect(authCodeUrl)
python
import urllib.parse

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

After redirecting, the user will see the bank's authorization screen showing:

  • The TPP name and purpose
  • The active periodic controls (e.g. "up to AED 500.00 per payment" if MaximumIndividualAmount is set, per-period value and count limits if set)
  • Any lifetime cumulative caps
  • The consent expiry date

User Experience

See User Experience for screen mockups of the Variable On-Demand Consent and Authorization pages the user sees at the bank.

Handling the Callback

Step 7 - Extracting the Authorization Code

After the user approves, the bank redirects to your redirect_uri:

https://yourapp.com/callback?code=fbe03604-baf2-4220-b7dd-05b14de19e5c&state=d2fe5e2c-77cd-4788-b0ef-7cf0fc8a3e54&iss=https://auth1.altareq1.sandbox.apihub.openfinance.ae
typescript
const params = new URLSearchParams(window.location.search)

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')
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")
if iss != ISSUER:         raise ValueError(f"Unexpected issuer: {iss}")

See Handling Authorization Callbacks for a full guide on state validation, issuer verification, and replay prevention.

Exchanging the Code for Tokens

Step 8 - POST /token (Authorization Code)

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 3
    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 } = 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 3
        "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

Token storage

Never store tokens in localStorage. Use httpOnly cookies or a server-side session store. See Tokens & Assertions for the full token lifecycle.

Initiating Payments On-Demand

Encrypt PII for Payment Initiation

Each POST /payments request carries its own PersonalIdentifiableInformation — a fresh JWE encrypted for that specific payment. This follows the same JWS-inside-JWE pattern used in Step 1, but uses the Domestic Payment PII Schema Object (AEBankServiceInitiation.AEDomesticPaymentPIIProperties) rather than the consent PII schema. The creditor fields are flat on Initiation at this stage — they are not wrapped in an array.

The schema defines PersonalIdentifiableInformation for POST /payments as a oneOf with two variants:

VariantFormNotes
Domestic Payment PII Schema Object (AEDomesticPaymentPIIProperties)objectUnencrypted form — shows the payment PII structure. For reference only.
Encrypted PII Object (AEJWEPaymentPII)stringCompact JWE string. MUST be used when invoking POST /payments.

Domestic Payment PII Schema Object must be strictly followed

The object you encrypt MUST conform exactly to AEDomesticPaymentPIIProperties. Field names, nesting, and data types are validated by the LFI after decryption — any deviation will result in payment rejection. Do not add undocumented fields or omit required ones.

See Personal Identifiable Information for the complete field reference, required vs optional fields, and creditor models for each domestic payment type.

Creditor must match the consent beneficiary model

The creditor supplied here must correspond to the beneficiary model set at consent time:

  • Single beneficiary (Initiation.Creditor[] had 1 entry): CreditorAccount.SchemeName, CreditorAccount.Identification, and CreditorAccount.Name must exactly match that entry. The LFI decrypts both PII tokens and compares them; any discrepancy results in rejection.
  • Multiple beneficiaries (2–10 entries): must exactly match one entry from the pre-approved list in the consent PII.
  • Open beneficiary (Initiation.Creditor[] omitted at consent): no consent-time match required — supply any valid creditor.

See Creditor for the full matching rules and field validation requirements.

Risk block is flexible per payment

Unlike the Creditor, the Risk block does not need to match the consent PII exactly. It should reflect the actual risk context of the individual payment — for example, a different Channel or updated TransactionIndicators for each payment under the consent.

Build the PII object according to the schema, then encrypt it using the same encryptPII helper from Step 1:

typescript
const paymentPii = {
  Initiation: {
    Creditor: [
      {
        Creditor: {
          Name: 'Ivan England',                  // must match consent PII (single/multiple beneficiary)
        },
        CreditorAccount: {
          SchemeName:     'IBAN',                // must match consent PII (single/multiple beneficiary)
          Identification: 'AE070331234567890123456',  // must match consent PII (single/multiple beneficiary)
          Name: {
            en: 'Ivan David England',            // must match consent PII (single/multiple beneficiary)
          },
        },
      },
    ],
  },
  // Risk can reflect the context of this specific payment
  Risk: {
    PaymentContextCode: 'BillPayment',
  },
}

const paymentEncryptedPII = await encryptPII(paymentPii, LFI_JWKS_URI, signingKey, SIGNING_KEY_ID)
// paymentEncryptedPII is a compact JWE string — embed it in the payment request below
python
payment_pii = {
    "Initiation": {
        "Creditor": [
            {
                "Creditor": {
                    "Name": "Ivan England",              # must match consent PII (single/multiple beneficiary)
                },
                "CreditorAccount": {
                    "SchemeName":     "IBAN",            # must match consent PII (single/multiple beneficiary)
                    "Identification": "AE070331234567890123456",  # must match consent PII (single/multiple beneficiary)
                    "Name": {
                        "en": "Ivan David England",      # must match consent PII (single/multiple beneficiary)
                    },
                },
            }
        ],
    },
    # Risk can reflect the context of this specific payment
    "Risk": {
        "PaymentContextCode": "BillPayment",
    },
}

payment_encrypted_pii = encrypt_pii(payment_pii, LFI_JWKS_URI)
# payment_encrypted_pii is a compact JWE string — embed it in the payment request below

See Personal Identifiable Information for the complete field reference, required vs optional fields, and creditor models for each domestic payment type.

See Message Encryption for details on fetching the LFI's JWKS and selecting the correct encryption key.

Step 9 - POST /payments

Include x-fapi-interaction-id and x-idempotency-key. If the customer is present at this point in the flow, also send x-fapi-customer-ip-address, x-customer-user-agent and x-fapi-auth-date if the customer has been authenticated. See Request Headers.

Unlike Single Instant Payment, this step can be called multiple times under the same consent. Each call specifies the actual amount for that payment — it must satisfy whichever control parameters exist on the consent (MaximumIndividualAmount, MaximumCumulativeValueOfPaymentsPerPeriod, and/or MaximumCumulativeNumberOfPaymentsPerPeriod), as well as any lifetime cumulative caps.

Fields that can vary per payment

Unlike Single Instant Payment, multi-payment consents do not require PaymentPurposeCode, DebtorReference, CreditorReference, or OpenFinanceBilling to match the consent exactly. Only ConsentId must match the authorized consent. Instruction.Amount must be within the parameters the consent allows for this payment type.

typescript
import { SignJWT } from 'jose'

const LFI_API_BASE = process.env.LFI_API_BASE_URL!

async function initiateVariablePayment(
  accessToken: string,
  consentId: string,
  amount: string,        // must satisfy the control parameters on the consent
  paymentEncryptedPII: string,  // from the PII step above
  idempotencyKey: string,
) {
  // Wrapped in `message` per AEPaymentRequestSigned
  const paymentPayload = {
    message: {
      Data: {
        ConsentId: consentId,                    // must match the authorized consent
        Instruction: {
          Amount: {
            Amount:   amount,                  // must be within consent parameters
            Currency: 'AED',
          },
        },
        PersonalIdentifiableInformation: paymentEncryptedPII,
        PaymentPurposeCode: 'ACM',
        DebtorReference:    'Subscription',
        CreditorReference:  'Subscription',
        OpenFinanceBilling: {
          Type: 'PushP2P',
        },
      },
    },
  }

  // AUTHORIZATION_SERVER_ISSUER is the `issuer` value from the LFI's .well-known/openid-configuration
  const signedPayment = await new SignJWT(paymentPayload)
    .setProtectedHeader({ alg: 'PS256', kid: SIGNING_KEY_ID, typ: 'JWT' })
    .setIssuedAt()
    .setIssuer(CLIENT_ID)
    .setAudience(AUTHORIZATION_SERVER_ISSUER)
    .setExpirationTime('5m')
    .sign(signingKey)

  const paymentResponse = await fetch(`${LFI_API_BASE}/open-finance/payment/v2.1/payments`, {
    method: 'POST',
    headers: {
      Authorization:                `Bearer ${accessToken}`,
      'Content-Type':               'application/jwt',
      'x-idempotency-key':          idempotencyKey,
      'x-fapi-interaction-id':      crypto.randomUUID(),
      'x-fapi-auth-date':           lastCustomerAuthDate,
      'x-fapi-customer-ip-address': customerIpAddress,
    },
    body: signedPayment,
    // agent: new https.Agent({ cert: transportCert, key: transportKey }),
  })

  const { Data: { PaymentId, Status } } = await paymentResponse.json()
  return { PaymentId, Status }
}

// First payment
const { PaymentId: pay1 } = await initiateVariablePayment(access_token, consentId, '149.99', paymentEncryptedPII, crypto.randomUUID())

// Second payment (days/weeks later using a refreshed access token)
const { PaymentId: pay2 } = await initiateVariablePayment(refreshedToken, consentId, '89.00', paymentEncryptedPII, crypto.randomUUID())
python
import time
from jose import jwt as jose_jwt

def initiate_variable_payment(
    access_token: str,
    consent_id: str,
    amount: str,          # must satisfy the control parameters on the consent
    payment_encrypted_pii: str,  # from the PII step above
    idempotency_key: str,
) -> dict:
    # Wrapped in `message` per AEPaymentRequestSigned
    payment_payload = {
        "message": {
            "Data": {
                "ConsentId":   consent_id,               # must match the authorized consent
                "Instruction": {
                    "Amount": {
                        "Amount":   amount,            # must be within consent parameters
                        "Currency": "AED",
                    }
                },
                "PersonalIdentifiableInformation": payment_encrypted_pii,
                "PaymentPurposeCode": "ACM",
                "DebtorReference":    "Subscription",
                "CreditorReference":  "Subscription",
                "OpenFinanceBilling": {
                    "Type": "PushP2P",
                },
            }
        }
    }

    # AUTHORIZATION_SERVER_ISSUER is the `issuer` value from the LFI's .well-known/openid-configuration
    now = int(time.time())
    signed_payment = jose_jwt.encode(
        {
            **payment_payload,
            "iss": CLIENT_ID,
            "aud": AUTHORIZATION_SERVER_ISSUER,
            "iat": now,
            "exp": now + 300,
        },
        signing_key,
        algorithm="PS256",
        headers={"kid": SIGNING_KEY_ID, "typ": "JWT"},
    )

    response = httpx.post(
        f"{LFI_API_BASE}/open-finance/payment/v2.1/payments",
        headers={
            "Authorization":               f"Bearer {access_token}",
            "Content-Type":                "application/jwt",
            "x-idempotency-key":           idempotency_key,
            "x-fapi-interaction-id":       str(uuid.uuid4()),
            "x-fapi-auth-date":            last_customer_auth_date,
            "x-fapi-customer-ip-address":  customer_ip_address,
        },
        content=signed_payment,
        # cert=("transport.crt", "transport.key"),
    )
    data = response.json()["Data"]
    return {"payment_id": data["PaymentId"], "status": data["Status"]}


# First payment
pay1 = initiate_variable_payment(access_token, consent_id, "149.99", payment_encrypted_pii, str(uuid.uuid4()))

# Second payment (days/weeks later using a refreshed access token)
pay2 = initiate_variable_payment(refreshed_token, consent_id, "89.00", payment_encrypted_pii, str(uuid.uuid4()))

Amount validation

The API Hub will reject a payment if Instruction.Amount violates any control parameter present on the consent — whether MaximumIndividualAmount, MaximumCumulativeValueOfPaymentsPerPeriod, MaximumCumulativeNumberOfPaymentsPerPeriod, or any lifetime cumulative cap.

Token refresh for subsequent payments

The initial access token expires after 10 minutes. For subsequent on-demand payments, use the refresh_token to obtain a new access token without re-involving the user:

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 refreshResponse = await fetch(TOKEN_ENDPOINT, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type:            'refresh_token',
    refresh_token:         storedRefreshToken,
    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: newToken, refresh_token: newRefresh } = await refreshResponse.json()
// Update your stored tokens
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"]

refresh_response = httpx.post(
    token_endpoint,
    data={
        "grant_type":            "refresh_token",
        "refresh_token":         stored_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        = refresh_response.json()
access_token  = tokens["access_token"]
refresh_token = tokens["refresh_token"]
# Update your stored tokens

See Tokens & Assertions for refresh token lifetimes and rotation policy.

A successful POST /payments

A 201 Created response is returned as a signed JWT (application/jwt). Verify the signature using the LFI's public signing key before reading the payload.

Response headers

HeaderDescription
LocationURL of the created payment resource — /open-finance/payment/v2.1/payments/{PaymentId}
x-fapi-interaction-idEcho of the interaction ID from the request
x-idempotency-keyEcho of the idempotency key from the request

Response body — Data

FieldRequiredDescription
PaymentIdYesLFI-assigned unique identifier for this payment resource (use this to poll for status)
ConsentIdYesThe consent this payment is bound to
StatusYesCurrent payment status — see status lifecycle below
StatusUpdateDateTimeYesISO 8601 datetime of the last status change
CreationDateTimeYesISO 8601 datetime when the payment resource was created
Instruction.AmountYesEchoes back the amount and currency from the request
PaymentPurposeCodeYesEchoes back the payment purpose code
OpenFinanceBillingYesEchoes back the billing parameters
PaymentTransactionIdNoEnd-to-end transaction ID generated by the Aani payment rails once the payment is submitted for settlement. Not present at Pending.
DebtorReferenceNoEchoes back the debtor reference if provided
RejectReasonCodeNoArray of { Code, Message } objects — present only when Status is Rejected

Status lifecycle

StatusDescription
PendingThe payment has been accepted by the LFI and queued for processing. This is the typical status immediately after creation.
AcceptedSettlementCompletedThe debtor's account has been debited.
AcceptedWithoutPostingThe receiving LFI has accepted the payment but has not yet credited the creditor account.
AcceptedCreditSettlementCompletedThe creditor account has been credited. Payment is fully settled.
RejectedThe payment was rejected. Inspect RejectReasonCode for the reason.
FieldDescription
SelfURL to this payment resource — use for status polling
RelatedURL to the associated consent — /open-finance/v2.1/payment-consents/{ConsentId}

Example response payload

The payload is the verified body of the signed JWT. Per AEPaymentIdResponseSigned, Data and Links are wrapped in a message envelope.

json
{
  "message": {
    "Data": {
      "PaymentId": "83b47199-90c2-4c05-9ef1-aeae68b0fc7c",
      "ConsentId": "b8f42378-10ac-46a1-8d20-4e020484216d",
      "Status": "Pending",
      "StatusUpdateDateTime": "2026-05-03T15:46:01+00:00",
      "CreationDateTime": "2026-05-03T15:46:01+00:00",
      "Instruction": {
        "Amount": {
          "Amount": "100.00",
          "Currency": "AED"
        }
      },
      "PaymentPurposeCode": "ACM",
      "DebtorReference": "Invoice 1234",
      "OpenFinanceBilling": {
        "Type": "PushP2P"
      }
    },
    "Links": {
      "Self": "https://api.lfi.example/open-finance/payment/v2.1/payments/83b47199-90c2-4c05-9ef1-aeae68b0fc7c",
      "Related": "https://api.lfi.example/open-finance/v2.1/payment-consents/b8f42378-10ac-46a1-8d20-4e020484216d"
    }
  }
}

Store the PaymentId

Persist PaymentId immediately — it is required to poll GET /payments/{PaymentId} for status updates. A payment typically moves from Pending to a terminal status within seconds, but network conditions may require polling.

See the POST /payments API reference for the full request and response schema.

Consent stays Authorized

After each successful payment, the consent remains in the Authorized state (unless cumulative caps are reached or the consent expires). You do not need to re-initiate the authorization flow.