Fixed Defined Schedule — API Guide 5 min read
A Fixed Defined Schedule consent authorises a TPP to initiate payments on a pre-agreed set of specific dates, each with a fixed exact amount. Rather than a recurring period, the TPP supplies an explicit schedule at consent time — listing each PaymentExecutionDate alongside the precise amount to be collected on that date. The user authorises once, approving the full schedule, and the TPP submits one payment per scheduled date without requiring re-authorisation.
Common use cases include fixed instalment plans, structured loan repayments, and membership or subscription billing where both the dates and exact amounts are known upfront.
Fixed Defined Schedule is the locked-amount variant of Variable Defined Schedule — same schedule shape, but each entry carries an exact Amount rather than a MaximumIndividualAmount ceiling.
What you need before initiating a Fixed Defined Schedule payment
Before initiating a Fixed Defined Schedule 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 API Hub (Authorisation Server) — the application must be registered with the API Hub (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.
End-to-end Fixed Defined Schedule
Sign and encrypt the consent PII
/par 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:
| Variant | Form | Notes |
|---|---|---|
| Domestic Payment PII Schema Object | object | Unencrypted form — shows the PII structure for domestic payments. For reference only. |
| International Payment PII Schema Object | object | Unencrypted form — shows the PII structure for international payments. For reference only. |
Encrypted PII Object (AEJWEPaymentPII) | string | Compact JWE string. MUST be used when invoking the PAR operation. |
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.
Initiation.Creditor is an array but must contain exactly one entry for this payment type. The consent is bound to that single recipient — every payment made under this consent must go to that account.
See Creditor for the field schema and validation rules.
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:
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": [
{
"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
See Message Encryption for details on fetching the LFI's JWKS and selecting the correct encryption key.
Bind PKCE and authorization details into a signed JWT
With your authorization_details ready, generate a PKCE code pair then use the buildRequestJWT() helper, passing payments openid as the scope.
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.
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: {
// MaximumCumulativeValueOfPayments: { Amount: '6000.00', Currency: 'AED' },
// MaximumCumulativeNumberOfPayments: 12,
PeriodicSchedule: {
Type: 'FixedDefinedSchedule',
Schedule: [
{ PaymentExecutionDate: '2026-08-01', Amount: { Amount: '500.00', Currency: 'AED' } },
{ PaymentExecutionDate: '2026-09-02', Amount: { Amount: '1200.00', Currency: 'AED' } },
{ PaymentExecutionDate: '2026-10-11', Amount: { Amount: '300.00', Currency: 'AED' } },
],
},
},
},
},
PersonalIdentifiableInformation: encryptedPII, // from Step 1
PaymentPurposeCode: 'ACM',
DebtorReference: 'Invoice 2026-08',
CreditorReference: 'Invoice 2026-08',
},
},
]
const requestJWT = await buildRequestJWT({
scope: 'payments openid',
codeChallenge,
authorizationDetails,
})
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.
Prove the application's identity to the API Hub
Use the signJWT() helper to build a client assertion proving your application's identity:
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(),
})
}
See Client Assertion for the full claims reference.
Push the request to the API Hub
Include x-fapi-interaction-id on the request — the API Hub echoes it in the response for end-to-end traceability. See Request Headers.
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()
You must present your transport certificate on every connection to the API Hub and resource APIs. See Certificates.
| Field | Description | Example |
|---|---|---|
request_uri | Single-use reference to your pushed authorization request | urn:ietf:params:oauth:request-uri:bwc4JDpSd7 |
expires_in | Seconds until the request_uri expires — redirect the user before this window closes | 90 |
Validate state and issuer on the redirect
After the user approves, the bank redirects 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.aeconst 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}`)
See Handling Authorization Callbacks for a full guide on state validation, issuer verification, and replay prevention.
Re-encrypt the creditor PII for each payment
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:
| Variant | Form | Notes |
|---|---|---|
Domestic Payment PII Schema Object (AEDomesticPaymentPIIProperties) | object | Unencrypted form — shows the payment PII structure. For reference only. |
Encrypted PII Object (AEJWEPaymentPII) | string | Compact JWE string. MUST be used when invoking POST /payments. |
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.
The creditor supplied here must correspond to the single beneficiary set at consent time. CreditorAccount.SchemeName, CreditorAccount.Identification, and CreditorAccount.Name must exactly match the entry in the consent PII. The LFI decrypts both PII tokens and compares them; any discrepancy results in rejection.
See Creditor for the full matching rules and field validation requirements.
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:
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
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.
Submit one payment per scheduled date
/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.
Submit one payment per scheduled date under this consent. On or around each PaymentExecutionDate, call POST/payments with the amount defined for that entry in the schedule. The API Hub will reject any payment that does not match the scheduled amount, duplicates a date already paid, or is submitted after the consent has expired.
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.
Each PaymentExecutionDate in the schedule may only be used for a single POST/payments call. Submitting a second payment for the same date will be rejected, regardless of the amount.
import { SignJWT } from 'jose'
const LFI_API_BASE = process.env.LFI_API_BASE_URL!
async function initiateFixedSchedulePayment(
accessToken: string,
consentId: string,
amount: string, // must match the Amount defined for this PaymentExecutionDate
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: 'Invoice 2026-08',
CreditorReference: 'Invoice 2026-08',
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 }
}
// Payment on 2026-08-01 — scheduled AED 500.00
const { PaymentId: aug } = await initiateFixedSchedulePayment(accessToken, consentId, '500.00', paymentEncryptedPII, crypto.randomUUID())
// Payment on 2026-09-02 — scheduled AED 1200.00 (using a refreshed access token)
const { PaymentId: sep } = await initiateFixedSchedulePayment(refreshedToken, consentId, '1200.00', paymentEncryptedPII, crypto.randomUUID())
The API Hub will reject a payment if Instruction.Amount does not match the Amount defined for the corresponding PaymentExecutionDate in the schedule, if that date has already been paid, or if any lifetime cumulative cap has been reached.
Use the refresh_token to keep the session alive
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:
// 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
See Tokens & Assertions for refresh token lifetimes and rotation policy.
201 Created — signed JWT response
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
| Header | Description |
|---|---|
Location | URL of the created payment resource — /open-finance/payment/v2.1/payments/{PaymentId} |
x-fapi-interaction-id | Echo of the interaction ID from the request |
x-idempotency-key | Echo of the idempotency key from the request |
Response body — Data
| Field | Required | Description |
|---|---|---|
PaymentId | Yes | LFI-assigned unique identifier for this payment resource (use this to poll for status) |
ConsentId | Yes | The consent this payment is bound to |
Status | Yes | Current payment status — see status lifecycle below |
StatusUpdateDateTime | Yes | ISO 8601 datetime of the last status change |
CreationDateTime | Yes | ISO 8601 datetime when the payment resource was created |
Instruction.Amount | Yes | Echoes back the amount and currency from the request |
PaymentPurposeCode | Yes | Echoes back the payment purpose code |
OpenFinanceBilling | Yes | Echoes back the billing parameters |
PaymentTransactionId | No | End-to-end transaction ID generated by the Aani payment rails once the payment is submitted for settlement. Not present at Pending. |
DebtorReference | No | Echoes back the debtor reference if provided |
RejectReasonCode | No | Array of { Code, Message } objects — present only when Status is Rejected |
Status lifecycle
| Status | Description |
|---|---|
Pending | The payment has been accepted by the LFI and queued for processing. This is the typical status immediately after creation. |
AcceptedSettlementCompleted | The debtor's account has been debited. |
AcceptedWithoutPosting | The receiving LFI has accepted the payment but has not yet credited the creditor account. |
AcceptedCreditSettlementCompleted | The creditor account has been credited. Payment is fully settled. |
Rejected | The payment was rejected. Inspect RejectReasonCode for the reason. |
Links
| Field | Description |
|---|---|
Self | URL to this payment resource — use for status polling |
Related | URL 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.
{
"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"
}
}
}
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.
After each successful payment, the consent remains in the Authorized state. You do not need to re-initiate the authorization flow between scheduled dates — use the token refresh flow to maintain a valid access token.
