LFI · Banking · Service Initiation · Fixed On Demand

Fixed On Demand — API Guide 15 min read

Fixed On Demand lets a TPP initiate multiple domestic payments at a fixed amount from a customer's account at your LFI via the API Hub. The customer authorises the consent once — approving a specific per-payment amount and periodic limits — and the TPP can then submit individual payments on-demand without re-authorisation. Payments run on AANI as the primary rail with UAEFTS as the fallback. This guide covers the Ozone Connect endpoints your LFI MUST implement so the Hub can serve every payment under the consent from creation through to execution and status retrieval.

The behavioural rules for each endpoint — validation conditions, error mappings, post-execution lifecycle — are in the Fixed On Demand Requirements. This guide covers the request and response shape of each endpoint, with code walkthroughs for the parts that need them: decrypting the PII, validating the creditor, matching the payment-time PII against the consent, and applying the duplicate-in-flight check that is specific to on-demand consent types.

01 Prerequisites

What you need before implementing Fixed On Demand

Before implementing Fixed On Demand, ensure the following are in place:

  • API Hub onboarded — Your API Hub instance is provisioned and your environment-specific configuration is complete.
  • Enc1 key pair generated and registered — The TPP encrypts PII to your LFI's Enc1 public key. Your LFI MUST hold the corresponding private key and be able to look it up by kid.
  • Consent Journey implemented — The Consent Journey API Guide MUST be implemented first. A payment cannot be initiated without an authorized consent.
  • Ozone Connect connectivity verified — Bidirectional mTLS connectivity is confirmed between the API Hub and your Ozone Connect base URL. See Connectivity & Certificates.
  • Fixed On Demand advertisedApiMetadata.FixedOnDemand.Supported is set to true on your authorisation server entry in the Trust Framework.
02 API Sequence Flow

End-to-end Fixed On Demand

Sequence diagramFixed On Demand API FlowClick to expand
05 POST /payments

Decrypt, validate, persist, return 201

POST/payments is the central endpoint your LFI implements for payment execution. The API Hub calls it each time the TPP submits a payment under an authorized Fixed On Demand consent. Your LFI MUST decrypt and validate the PII, match it against the consent, run the synchronous validations listed in POST /payments Requirements — including the duplicate-in-flight check that is specific to on-demand consent types — create the payment record, and return 201 with the assigned PaymentId.

Screening, rail submission, and status propagation happen after the 201 response — see After returning 201.

Common request headers

HeaderRequiredDescription
o3-provider-idYesIdentifier for your LFI registered in the Hub
o3-aspsp-idYes (deprecated)Deprecated alias for o3-provider-id
o3-caller-org-idYesOrganisation ID of the TPP making the underlying request
o3-caller-client-idYesOIDC client ID of the TPP application
o3-caller-software-statement-idYesSoftware statement ID of the TPP application
o3-api-uriYesThe parameterised URL of the API being called by the TPP
o3-api-operationYesThe HTTP method (POST)
o3-ozone-interaction-idYesHub-generated interaction ID
o3-consent-idYesThe consentId for which this call is being made
o3-psu-identifierYesBase64-encoded customer identifier JSON object
o3-caller-interaction-idNoInteraction ID passed in by the TPP, if present
Customer-set FAPI headers are inside the body, not the HTTP request

The headers the TPP set on its original call to the API Hub — including x-fapi-interaction-id, x-fapi-auth-date, x-customer-user-agent, and x-idempotency-key — are forwarded to your LFI inside the request body as requestHeaders, not on the HTTP headers of the API Hub → LFI call. Unlike Single Instant Payment, Fixed On Demand does not require x-fapi-customer-ip-address — the customer may not be present at the time the TPP submits an on-demand payment.

Request body

Content-Type: application/json

Top-level fields

FieldTypeRequiredDescription
requestUrlstringNoThe TPP-facing resource URL the TPP called
paymentTypestringYesMUST be cbuae-payment for domestic Fixed On Demand
request.DataobjectYesThe payment payload
requestHeadersobjectYesThe complete set of HTTP headers the TPP sent to the API Hub
tppobjectYesThe TPP's directory record. See The tpp and decodedSsa Context Blocks
supplementaryInformationobjectNoFree-form pass-through context

request.Data

FieldTypeRequiredDescription
ConsentIdstringYesMUST equal the o3-consent-id header
Instruction.Amount.AmountstringYesFor Fixed On Demand this MUST equal the consent's PeriodicSchedule.Amount — the Hub enforces this before forwarding
Instruction.Amount.CurrencystringYesMUST be AED for domestic payments
PaymentPurposeCodestringYes3-letter ISO 20022 purpose code, e.g. SUBS
PersonalIdentifiableInformationstringNoEncrypted PII payload as a JWE compact string
DebtorReferencestringNoReference shown on the debtor's statement
CreditorReferencestringNoReference shown on the creditor's statement
OpenFinanceBilling.TypestringYesBilling type
OpenFinanceBilling.MerchantIdstringNoOptional merchant identifier

Request example

POST /payments request bodyjson
{
  "requestUrl": "https://rs1.[LFICode].apihub.openfinance.ae/open-finance/payment/v2.1/payments",
  "paymentType": "cbuae-payment",
  "request": {
    "Data": {
      "ConsentId": "cac2381a-7111-4c5f-bc2f-4319a93da7c5",
      "Instruction": {
        "Amount": { "Amount": "49.00", "Currency": "AED" }
      },
      "PaymentPurposeCode": "SUBS",
      "PersonalIdentifiableInformation": "eyJhbGciOiJSU0EtT0FFUC0yNTYi...",
      "OpenFinanceBilling": { "Type": "Collection" }
    }
  },
  "requestHeaders": {
    "o3-provider-id": "lfi-123",
    "o3-caller-org-id": "tpp-456",
    "o3-caller-client-id": "client-789",
    "o3-api-uri": "/open-finance/payment/v2.1/payments",
    "o3-api-operation": "POST",
    "o3-ozone-interaction-id": "ozone-xyz",
    "o3-consent-id": "cac2381a-7111-4c5f-bc2f-4319a93da7c5",
    "o3-psu-identifier": "eyJwczoi...",
    "x-fapi-interaction-id": "0f4d3a16-9e27-4f8d-9a5a-3a2f7e9c1b22",
    "x-fapi-auth-date": "Tue, 18 Apr 2026 10:14:22 GMT",
    "x-idempotency-key": "idem-2026-04-18-001"
  },
  "tpp": {
    "clientId": "1675793e-d6e3-4954-96c8-acb9aaa83c53",
    "orgId": "a1b2c3d4-e5f6-7890-abcd-ef0123456789",
    "tppId": "fdd6e0ac-ba7a-4bc4-a986-c45c5daaaf00",
    "tppName": "Example TPP",
    "softwareStatementId": "XvAjPeeYZAdWwrFF..",
    "decodedSsa": {
      "client_id": "1675793e-d6e3-4954-96c8-acb9aaa83c53",
      "client_name": "Example TPP",
      "roles": ["BSIP"]
    }
  },
  "supplementaryInformation": {}
}

Reading the PII at payment time

The payment-time PII follows a different shape from the consent-time PII:

  • Initiation.Creditor is a single object, not an array.
  • DebtorAccount is absent.
  • The schema is AEBankServiceInitiation.AEDomesticPaymentPIIProperties.
typescript
async function decryptAndValidatePaymentPII(piiJwe: string) {
  const pii = await decryptPii(piiJwe) // shared decrypt helper
  validatePaymentPiiSchema(pii)         // AEDomesticPaymentPIIProperties
  return pii
}

If decryption fails, reject with 400 JWE.DecryptionError. If schema validation fails, reject with 400 Body.InvalidFormat.

Per POST /payments Requirements rule 2, the submitted creditor MUST exactly match the single creditor that was on the consent at consent time. Mismatch → 400 Consent.FailsControlParameters.

Pattern A — LFI persisted the decrypted creditor at consent time

typescript
async function matchPaymentCreditorToConsent(
  consentId: string,
  paymentPii: { Initiation: { Creditor: ConsentTimeCreditor } }
): Promise<void> {
  const consentCreditor = await consentStore.getCreditor(consentId)
  if (!consentCreditor) {
    throw httpError(400, 'Consent.Invalid', `No stored consent for ${consentId}.`)
  }

  if (!isExactMatch(consentCreditor, paymentPii.Initiation.Creditor)) {
    throw httpError(400, 'Consent.FailsControlParameters',
      'Payment creditor does not match the creditor authorised on the consent.')
  }
}

function isExactMatch(consentCreditor: any, paymentCreditor: any): boolean {
  return (
    consentCreditor.CreditorAccount.SchemeName === paymentCreditor.CreditorAccount.SchemeName &&
    consentCreditor.CreditorAccount.Identification === paymentCreditor.CreditorAccount.Identification &&
    consentCreditor.CreditorAccount.Name?.en === paymentCreditor.CreditorAccount.Name?.en &&
    consentCreditor.CreditorAccount.Name?.ar === paymentCreditor.CreditorAccount.Name?.ar &&
    consentCreditor.CreditorAgent?.SchemeName === paymentCreditor.CreditorAgent?.SchemeName &&
    consentCreditor.CreditorAgent?.Identification === paymentCreditor.CreditorAgent?.Identification
  )
}

Pattern B — LFI did not persist the consent-time PII

If your LFI did not persist the decrypted PII at consent time, fetch the consent from the API Hub via GET/consents/{consentId}, decrypt the consent's PersonalIdentifiableInformation field, and run the same isExactMatch comparison against Initiation.Creditor[0].

Duplicate-in-flight check

Per POST /payments Requirements rule 6, Fixed On Demand payments are subject to a duplicate-in-flight check that on-demand consent types carry but one-off and scheduled payments do not. Before the payment record is created, your LFI MUST check whether another payment under the same consent, with the same creditor and the same instructed amount, is currently in Pending status. If so, reject the new request with 409 Payment.DuplicateInFlight.

This is distinct from x-idempotency-key handling: the idempotency key catches TPP retries of the same HTTP request, while this rule catches genuinely separate payment intents that happen to duplicate a still-in-flight one.

typescript
async function rejectIfDuplicateInFlight(
  consentId: string,
  paymentCreditor: any,
  amount: { Amount: string; Currency: string }
): Promise<void> {
  const inFlight = await paymentStore.findInFlight({
    consentId,
    status: 'Pending',
    creditorIban: paymentCreditor.CreditorAccount.Identification,
    amount: amount.Amount,
    currency: amount.Currency,
  })
  if (inFlight) {
    throw httpError(409, 'Payment.DuplicateInFlight',
      'A payment with the same creditor and amount is already in flight under this consent.')
  }
}

Once the prior payment has left Pending (reached AcceptedSettlementCompleted, AcceptedCreditSettlementCompleted, AcceptedWithoutPosting, or Rejected), a subsequent identical payment is permitted.

Response

Content-Type: application/json

Return 201 on successful payment record creation.

FieldTypeRequiredDescription
data.idstringYesThe LFI-assigned PaymentId
data.consentIdstringNoThe consent under which the payment was created
data.paymentTransactionIdstringNoEnd-to-end identifier from the rail
data.statusstringYesOne of Pending, AcceptedSettlementCompleted, AcceptedCreditSettlementCompleted, AcceptedWithoutPosting, Rejected, Received
data.statusUpdateDateTimestringYesISO 8601 timestamp
data.creationDateTimestringYesISO 8601 timestamp
data.instruction.AmountobjectNoThe payment amount and currency
data.paymentPurposeCodestringYesThe purpose code from the request
data.openFinanceBilling.TypestringYesThe billing type from the request

Example — successful initiation

201 Createdjson
{
  "data": {
    "id": "5ff155ea-853f-480c-ac74-1eaed7c1201f",
    "consentId": "cac2381a-7111-4c5f-bc2f-4319a93da7c5",
    "status": "Pending",
    "statusUpdateDateTime": "2026-04-18T10:14:23Z",
    "creationDateTime": "2026-04-18T10:14:23Z",
    "instruction": {
      "Amount": { "amount": "49.00", "currency": "AED" }
    },
    "paymentPurposeCode": "SUBS",
    "openFinanceBilling": { "Type": "Collection" }
  },
  "meta": {}
}

Error responses

Only return an error when the request is invalid. The errorCode values are drawn from the POST /payments OpenAPI schema Error400 / Error403 / Error409 enums.

400 — Bad request

errorCodeWhen to use
Body.InvalidFormatBody fails schema validation
Resource.InvalidFormatA field is present but not syntactically valid
Consent.InvalidThe consent referenced is unknown to the LFI or has been revoked
Consent.FailsControlParametersThe payment-time creditor does not match the consent-time creditor
Consent.BusinessRuleViolationAn LFI-side business rule blocks the payment
JWE.DecryptionErrorPII JWE cannot be decrypted with any registered Enc1 key
JWE.InvalidHeaderPII JWE header is malformed
JWS.InvalidSignature / JWS.Malformed / JWS.InvalidClaim / JWS.InvalidHeaderPII inner JWS fails verification
GenericRecoverableError / GenericErrorOther validation errors

403 — Forbidden

errorCodeWhen to use
AccessToken.InvalidScopeThe Hub's token does not include the required scope
Consent.AccountTemporarilyBlockedDebtor account is Inactive, Dormant, or Suspended
Consent.PermanentAccountAccessFailureDebtor account is Closed, Deceased, or Unclaimed
Consent.TransientAccountAccessFailureDebtor account temporarily inaccessible

409 — Conflict

errorCodeWhen to use
Payment.DuplicateInFlightAnother payment with the same creditor and amount under the same consent is still Pending

Example error response

error responsejson
{
  "errorCode": "Payment.DuplicateInFlight",
  "errorMessage": "A payment with the same creditor and amount is already in flight under this consent."
}

After returning 201

The 201 means the payment record exists at your LFI; it does not mean the payment has settled. The lifecycle from here is asynchronous and is the LFI's responsibility:

StageLFI behaviourReference
ScreeningRun fraud / sanctions / AML controls. SHOULD complete within 3 seconds. On failure, PATCH the payment to Rejected with an LFI.-namespaced reject reasonScreening Checks
Rail submissionSubmit to AANI as primary. Fall back to UAEFTS automatically if AANI is unavailableRail Submission
Status propagationOn every rail status change, call PATCH/payment-log/{id}Payment Status
Rail rejectionIf the rail rejects the payment, PATCH paymentResponse.status: Rejected with RejectReasonCode namespaced as AANI. or FTS.Rail Submission
Status retrievalContinue serving GET/payments/{paymentId} for at least 1 yearGET /payments/{paymentId} rules below
06 PATCH /payment-log/:id

Update payment status on the API Hub

PATCH/payment-log/:id updates the payment status on the API Hub. The Hub uses the update to send asynchronous notifications to TPPs and to maintain accurate state for billing and limit calculations.

Request body

Uses literal flat-key JSON (the dots are part of the key, not nested objects):

FieldTypeRequiredDescription
paymentResponse.statusstringYesPending, AcceptedSettlementCompleted, AcceptedCreditSettlementCompleted, AcceptedWithoutPosting, or Rejected
paymentResponse.paymentTransactionIdstringConditionalThe end-to-end identifier assigned by the rail. Once set, MUST NOT change
paymentResponse.OpenFinanceBilling.numberOfSuccessfulTransactionsintegerNoNumber of successful transactions (typically 1 per payment)
paymentResponse.RejectReasonCode[]arrayConditionalRequired when status is Rejected
paymentResponse.RejectReasonCode[].CodestringYes (in array)Pattern: ^(LFI|AANI|FTS)\.[A-Za-z0-9]+$
paymentResponse.RejectReasonCode[].MessagestringYes (in array)Sanitised, customer-relayable description

Example — successful settlement

successful settlementjson
{
  "paymentResponse.status": "AcceptedSettlementCompleted",
  "paymentResponse.paymentTransactionId": "de857816-3016-4567-86b6-8f418e36fb27"
}

Example — rail rejection

rail rejectionjson
{
  "paymentResponse.status": "Rejected",
  "paymentResponse.paymentTransactionId": "de857816-3016-4567-86b6-8f418e36fb27",
  "paymentResponse.RejectReasonCode": [
    {
      "Code": "AANI.AM04",
      "Message": "Payment request cannot be executed as insufficient funds at debtor account."
    }
  ]
}

Example — LFI screening rejection

LFI screening rejectionjson
{
  "paymentResponse.status": "Rejected",
  "paymentResponse.RejectReasonCode": [
    {
      "Code": "LFI.ScreeningRejected",
      "Message": "Payment rejected by LFI screening controls."
    }
  ]
}

Response

A successful PATCH returns 204 No Content with no body.

07 GET /payments/:paymentId

Serve payment status to the TPP

GET/payments/:paymentId backs the TPP request GET https://rs1.LFICODE.apihub.openfinance.ae/open-finance/payment/v2.1/payments/{PaymentId}. The TPP polls this to observe screening outcomes, rail settlement, and any subsequent rejection.

Response

The response shape mirrors the POST/payments 201 response.

GET responsejson
{
  "data": {
    "id": "5ff155ea-853f-480c-ac74-1eaed7c1201f",
    "consentId": "cac2381a-7111-4c5f-bc2f-4319a93da7c5",
    "paymentTransactionId": "de857816-3016-4567-86b6-8f418e36fb27",
    "status": "AcceptedSettlementCompleted",
    "statusUpdateDateTime": "2026-04-18T10:14:31Z",
    "creationDateTime": "2026-04-18T10:14:23Z",
    "instruction": {
      "Amount": { "amount": "49.00", "currency": "AED" }
    },
    "paymentPurposeCode": "SUBS",
    "openFinanceBilling": { "Type": "Collection" }
  },
  "meta": {}
}

Behavioural rules

#Rule
1Sustain period — Serve GET/payments/{paymentId} for at least 1 year from the payment's creation date
2Status consistency with the API Hub — The Status returned MUST exactly match the most recent value PATCHed to the API Hub
3paymentTransactionId consistency — Once the rail has assigned the end-to-end identifier, this endpoint MUST return the same value

Errors

StatuserrorCodeWhen to use
404Resource.NotFoundNo payment exists for the supplied paymentId
403Consent.AccountTemporarilyBlocked / Consent.PermanentAccountAccessFailureDebtor account inaccessible
500GenericRecoverableError / GenericErrorTransient or unrecoverable server error