Release candidate

v2.2-rc2 is not ratified and MUST NOT be used for production. Current: v2.1. Changelog

This is v2.2-rc2 — a release candidate for the v2.2 standards, published for review and not yet ratified. It MUST NOT be used as the basis for a production implementation. For the current standards, switch to v2.1 using the version selector. See the v2.1 → v2.2-rc2 changelog for every change in this version.

LFI · Banking · Service Initiation · Single Instant Payment

Single Instant Payment — API Guide 14 min read

Single Instant Payment lets a TPP initiate a one-off domestic payment from a customer's account at your LFI via the API Hub. The payment runs 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 a TPP payment from consent creation through to execution and status retrieval.

The behavioural rules for each endpoint — validation conditions, error mappings, post-execution lifecycle — are in the Single Instant Payment 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 reading the FAPI customer headers.

01 Prerequisites

What you need before implementing Single Instant Payment

Before implementing Single Instant Payment, 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.
  • Single Instant Payment advertised — ApiMetadata.SingleInstantPayment.Supported is set to true on your authorisation server entry in the Trust Framework.
02 API Sequence Flow

End-to-end Single Instant Payment

Sequence diagramSingle Instant Payment API FlowClick to expand
05 POST /payments

Decrypt, validate, persist, return 201

POST/payments

POST/payments is the central endpoint your LFI implements for payment execution. The API Hub calls it after a TPP payment instruction has passed all Hub-side validations. Your LFI MUST decrypt and validate the PII, match it against the consent, run the synchronous validations listed in POST /payments Requirements, 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. The payment MUST be submitted to the rail within 3 seconds of returning the 201 — see Payment Timing.

Common request headers

HeaderRequiredDescription
o3-provider-idYesIdentifier for your LFI registered in the Hub
o3-aspsp-idYes (deprecated)Deprecated alias for o3-provider-id. Will be removed in a future version — use 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 of the operation carried out by the TPP (POST)
o3-ozone-interaction-idYesHub-generated interaction ID. Equals o3-caller-interaction-id if the TPP provided one
o3-consent-idYesThe consentId for which this call is being made — the lookup key for the stored consent context (see Matching the PII against the consent)
o3-psu-identifierYesBase64-encoded customer identifier JSON object — the opaque LFI-issued reference patched onto the consent at authorization
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-customer-ip-address, x-fapi-auth-date, x-fapi-interaction-id, 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. See Reading the FAPI customer headers.

Request body

Content-Type: application/json

The Hub sends a plain JSON payload (not a JWS) containing the payment details, the headers the TPP supplied, and the TPP's directory record.

Top-level fields

FieldTypeRequiredDescription
requestUrlstringNoThe TPP-facing resource URL the TPP called, e.g. https://rs1.[LFICode].apihub.openfinance.ae/open-finance/payment/v2.2/payments
paymentTypestringYesThe payment type. MUST be cbuae-payment for domestic Single Instant Payment
request.DataobjectYesThe payment payload — see request.Data below
requestHeadersobjectYesThe complete set of HTTP headers the TPP sent to the API Hub. The TPP's FAPI headers live here — see Reading the FAPI customer headers
tppobjectYesThe TPP's directory record (clientId, orgId, tppId, tppName, softwareStatementId, decodedSsa, optional directoryRecord). Not required for payment execution — see The tpp and decodedSsa Context Blocks for field-by-field detail and the situations where an LFI uses them
supplementaryInformationobjectNoFree-form pass-through context. The LFI MUST safely ignore unrecognised properties

request.Data

FieldTypeRequiredDescription
ConsentIdstringYesThe consent the payment is being executed under. MUST equal the o3-consent-id header
Instruction.Amount.AmountstringYesDecimal amount with two fraction digits, e.g. 100.00
Instruction.Amount.CurrencystringYesISO-4217 currency code. MUST be AED for domestic payments
PaymentPurposeCodestringYes3-letter ISO 20022 purpose code, e.g. ACM
PersonalIdentifiableInformationstringNoEncrypted PII payload as a JWE compact string. Required for the Creditor at payment time — see Reading the PII at payment time
DebtorReferencestringNoReference shown on the debtor's statement
CreditorReferencestringNoReference shown on the creditor's statement
OpenFinanceBilling.TypestringYesBilling type, e.g. Collection, PushP2P, PullP2P, Me2Me
OpenFinanceBilling.MerchantIdstringNoOptional merchant identifier
UseFTSbooleanNoPassed through from the TPP. When true, the payment MUST be settled over UAEFTS — see Routing with UseFTS. Defaults to false

For the full schema — including tpp and decodedSsa field-by-field — see the POST /payments API Reference.

Request example

POST /payments request bodyjson
{
  "requestUrl": "https://rs1.[LFICode].apihub.openfinance.ae/open-finance/payment/v2.2/payments",
  "paymentType": "cbuae-payment",
  "request": {
    "Data": {
      "ConsentId": "cac2381a-7111-4c5f-bc2f-4319a93da7c5",
      "Instruction": {
        "Amount": { "Amount": "100.00", "Currency": "AED" }
      },
      "PaymentPurposeCode": "ACM",
      "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.2/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-fapi-customer-ip-address": "192.0.2.45",
    "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 — the consent fixed exactly one creditor at consent time.
  • DebtorAccount is absent — the debtor was selected and pinned during consent authorisation.
  • The schema to validate against is AEBankServiceInitiation.AEDomesticPaymentPIIProperties in uae-ozone-connect-bank-service-initiation-openapi.yaml — not the consent-time schema.

The decrypt + decode flow is identical to consent time — read the kid, decrypt with the matching Enc1 private key, decode the JWS payload. Re-use the helper from Decrypting and validating the PII; only swap the schema:

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 (missing required field, wrong type, additional property), reject with 400 Body.InvalidFormat.

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

The link between the payment and the consent is the o3-consent-id request header (also surfaced as request.Data.ConsentId in the body). Two implementation patterns are valid; pick whichever matches your LFI's persistence model:

Pattern A — LFI persisted the decrypted creditor at consent time

The most common pattern. At consent validation, after you decrypted and validated the consent-time PII, you persisted the single creditor entry keyed by consentId. At payment time you fetch it and deep-compare against the payment-time creditor.

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 {
  // Compare every field that was authorised — case-sensitive.
  // CreditorAccount: SchemeName, Identification, Name.en, Name.ar
  // CreditorAgent (if present on the consent): SchemeName, Identification
  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] (the consent-time creditor is an array of one).

Pattern B trades a stored creditor for a network round-trip and a second decryption on every payment. Choose Pattern A unless persistence is not an option for your LFI.

Reading the FAPI customer headers

The TPP's FAPI headers (x-fapi-customer-ip-address, x-fapi-auth-date, x-fapi-interaction-id, x-customer-user-agent, x-idempotency-key) are forwarded inside body.requestHeaders — they are not on the actual HTTP headers of the API Hub → LFI call. Read them off the body.

Per POST /payments Requirements rule 3, x-fapi-customer-ip-address MUST be present on a Single Instant Payment (the customer is actively present at the time of the call) and MUST be a valid IPv4 or IPv6 address. Missing or malformed → 400 Body.InvalidFormat.

typescript
import net from 'node:net'

function readCustomerIpAddress(body: { requestHeaders: Record<string, string> }): string {
  const ip = body.requestHeaders['x-fapi-customer-ip-address']
  if (!ip || net.isIP(ip) === 0) {
    throw httpError(400, 'Body.InvalidFormat',
      'x-fapi-customer-ip-address is missing or not a valid IPv4/IPv6 address.')
  }
  return ip
}

The validated IP is typically logged on the payment record and surfaced to fraud and screening systems alongside x-fapi-auth-date and x-customer-user-agent.

Response

Content-Type: application/json

Return 201 on successful payment record creation.

FieldTypeRequiredDescription
data.idstringYesThe LFI-assigned PaymentId. MUST be unique within your payment system, MUST NOT be reassigned, and MUST resolve to the same payment for the full 1-year GET/payments/{paymentId} sustain window — see Rule 7 of POST /payments Requirements
data.consentIdstringNoThe consent under which the payment was created
data.paymentTransactionIdstringNoEnd-to-end identifier for the payment on its rail. Omit until set — do not return an empty string
data.statusstringYesOne of Pending, AcceptedSettlementCompleted, AcceptedCreditSettlementCompleted, AcceptedWithoutPosting, Rejected, Received
data.statusUpdateDateTimestringYesISO 8601 timestamp of the last status update
data.creationDateTimestringYesISO 8601 timestamp the payment record was created
data.instruction.Amount.amount / Amount.currencystringNoThe payment amount and currency
data.paymentPurposeCodestringYesThe purpose code from the request
data.openFinanceBilling.TypestringYesThe billing type from the request
metaobjectNoFree-form metadata

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": "100.00", "currency": "AED" }
    },
    "paymentPurposeCode": "ACM",
    "openFinanceBilling": { "Type": "Collection" }
  },
  "meta": {}
}

Error responses

Only return an error when the request is invalid or a server condition prevents you from responding. All error bodies MUST include errorCode and errorMessage. The errorCode values are drawn from the POST /payments OpenAPI schema Error400 / Error403 enums.

400 — Bad request

errorCodeWhen to use
Body.InvalidFormatBody is absent, not valid JSON, fails schema validation, or x-fapi-customer-ip-address is missing/malformed
Resource.InvalidFormatA field is present but not syntactically valid
Consent.InvalidThe consent referenced by o3-consent-id 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 (only relevant if you have opted in to verifying the TPP's signature)
GenericRecoverableErrorRecoverable validation error not covered above — Hub may retry
GenericErrorUnrecoverable validation error not covered above

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 — Hub may retry after a delay
GenericRecoverableError / GenericErrorOther forbidden conditions

409 / 500

409 for conflict conditions and 500 for transient/unrecoverable server errors. Use GenericRecoverableError if the Hub may retry, GenericError otherwise.

504 — Gateway Timeout

You MAY return 504 when you cannot complete processing within your internal time limit. The API Hub treats it as a timeout. The body for this status is not standardised and no schema is defined for it. A 504 is a 5xx response, so it counts as a failed request under the Ozone Connect Availability Policy.

Example error response

error responsejson
{
  "errorCode": "Consent.FailsControlParameters",
  "errorMessage": "Payment creditor does not match the creditor authorised on the consent."
}

After returning 201

The 201 returned to the API Hub 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 the LFI's fraud screening, transaction monitoring and AML checks on the payment record. These MUST complete in time for the payment to be submitted to the rail within 3 seconds of returning the 201Screening Checks
Rail submissionRoute as set by UseFTS — see Routing with UseFTS. By default, submit to AANI as primary. Fall back to UAEFTS automatically if AANI is unavailable or the receiving bank cannot receive via AANI — no TPP/customer intervention. MUST submit the payment to the rail within 3 seconds of returning the 201Rail Submission
Status propagationCall PATCH/payment-log/{id} on the API Hub Consent Manager immediately on each of: submission to AANI or FTS — PATCH AcceptedSettlementCompleted, without waiting for any response from the rail; and the rail’s response — PATCH the terminal status. Every PATCH MUST carry paymentResponse.paymentRail, and on AANI and FTS also paymentResponse.paymentTransactionIdPayment Timing, Payment Status
Rail rejectionIf the rail rejects the payment, PATCH paymentResponse.status: Rejected with rejectReasonCode[].code namespaced to the rail named in paymentResponse.paymentRail (AANI., FTS. or LFI.) and a sanitised message for relay to the TPPRail Submission
Status retrievalContinue serving GET/payments/{paymentId} for at least 1 year, with Status and paymentTransactionId consistent with the most recent PATCHGET /payments/{paymentId} rules below

Routing with UseFTS

The TPP may set UseFTS on the payment request to require settlement over UAEFTS. The API Hub passes the value through as request.Data.UseFTS. Route the payment as follows:

PaymentLFI behaviour
UseFTS is trueMUST settle the payment over UAEFTS and MUST NOT settle it over AANI. If UAEFTS is not available, MUST reject the payment rather than fall back to AANI: PATCH paymentResponse.status: Rejected with paymentResponse.paymentRail: FTS and an FTS.-namespaced reject reason
UseFTS is false or absentApply the default routing: submit to AANI as primary, and fall back to UAEFTS where your routing rules require it
Debtor and creditor accounts both held at your LFIIgnore UseFTS. Book the payment on your own ledger and report paymentResponse.paymentRail: LFI

PATCH delivery is durable: retry transient 5xx/timeout failures with exponential backoff; raise 4xx failures for operational investigation rather than retrying.

06 PATCH /payment-log/:id

Update payment status on the API Hub

PATCH/payment-log/:id

This endpoint 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. The LFI calls it for every Open Finance-relevant status transition after the 201 has been returned to the Hub on POST/payments.

Request headers

HeaderRequiredDescription
o3-provider-idYesIdentifier for your LFI registered in the Hub
o3-caller-org-idYesOrganisation ID of the TPP making the underlying request
o3-caller-client-idYesOIDC client ID of the TPP application
o3-api-uriYesThe parameterised URL of the API being called by the TPP
o3-api-operationYesThe HTTP method of the operation carried out by the TPP (PATCH)
o3-ozone-interaction-idYesHub-generated interaction ID. Equals o3-caller-interaction-id if the TPP provided one
o3-consent-idYesThe consent backing this payment
o3-psu-identifierYesBase64-encoded psuIdentifier JSON object
o3-caller-interaction-idNoInteraction ID passed in by the TPP, if present

Path parameters

ParameterTypeDescription
idstringIdentifier of the payment log entry being updated — the data.id returned from POST/payments

Request body

Content-Type: application/json

The PATCH body uses literal flat-key JSON (the dots are part of the key, not nested objects):

From v2.2 the rail selects the contract.paymentResponse.paymentRail is REQUIRED on every PATCH and chooses one of three request bodies. The rail determines which statuses the payment may move to, whether paymentTransactionId applies, and which reject reason code namespace is accepted. Pending is no longer patchable — a payment is Pending precisely because no rail has been selected yet — and paymentResponse.OpenFinanceBilling is no longer patchable through this operation. See changelog §3.

On AANI and FTS, the LFI MUST PATCH AcceptedSettlementCompleted immediately on submitting the payment to the rail, and MUST have submitted it within 3 seconds of returning the 201 on POST/payments. AcceptedSettlementCompleted is not terminal: it records the payment having been handed to the rail. The LFI MUST PATCH a second time immediately on receiving the rail’s response — to AcceptedWithoutPosting on AANI, to AcceptedCreditSettlementCompleted on FTS, or to Rejected on either. On LFI, where both accounts are held at this LFI and the payment is booked on its own ledger, AcceptedCreditSettlementCompleted and Rejected are both terminal and no second PATCH follows. See Payment Timing for the full sequence.

FieldTypeRequiredDescription
paymentResponse.paymentRailstringYesAANI, FTS, or LFI for a payment settled on the LFI’s own ledger. REQUIRED on every PATCH from v2.2, and selects the request body: the rail determines the permitted statuses, whether paymentTransactionId applies, and which reject reason code namespace is accepted. Records how the payment was executed, not how it was requested
paymentResponse.statusstringYesConstrained by the rail. AANI: AcceptedSettlementCompleted (not terminal), AcceptedWithoutPosting, Rejected. FTS: AcceptedSettlementCompleted (not terminal), AcceptedCreditSettlementCompleted, Rejected. LFI: AcceptedCreditSettlementCompleted, Rejected. Pending is NOT patchable from v2.2. See PATCH /payment-log/{id}
paymentResponse.paymentTransactionIdstringConditionalRequired on every PATCH on AANI and FTS — including AcceptedSettlementCompleted and Rejected — and not accepted on LFI, where the payment has no such identifier. On AANI it is the pacs.008 TxId your LFI assigned to the payment, which MUST start with OFP; on FTS it is the payment’s UAEFTS transaction identifier. Once set, MUST NOT change
paymentResponse.rejectReasonCode[]arrayConditionalRequired when paymentResponse.status is Rejected, and MUST NOT be sent with any other status. Append new reasons rather than replacing — preserves history
paymentResponse.rejectReasonCode[].codestringYes (in array)Namespaced to the rail named on this PATCH, and to that rail only: ^AANI\.[A-Za-z0-9]+$, ^FTS\.[A-Za-z0-9]+$, or ^LFI\.[A-Za-z0-9]+$. A rejection raised at the LFI before submission — screening, for example — still carries the namespace of the rail the payment was routed to
paymentResponse.rejectReasonCode[].messagestringYes (in array)Sanitised, customer-relayable description. MUST NOT reveal detection logic, sanctions matches, or internal case identifiers

Example — handed to AANI (first PATCH)

handed to AANIjson
{
  "paymentResponse.paymentRail": "AANI",
  "paymentResponse.status": "AcceptedSettlementCompleted",
  "paymentResponse.paymentTransactionId": "OFP7d2c1e9a4b6f4c0e8a1b"
}

Example — AANI confirmation (second PATCH)

AANI confirmationjson
{
  "paymentResponse.paymentRail": "AANI",
  "paymentResponse.status": "AcceptedWithoutPosting",
  "paymentResponse.paymentTransactionId": "OFP7d2c1e9a4b6f4c0e8a1b"
}

Example — rail rejection

rail rejectionjson
{
  "paymentResponse.paymentRail": "AANI",
  "paymentResponse.status": "Rejected",
  "paymentResponse.paymentTransactionId": "OFP7d2c1e9a4b6f4c0e8a1b",
  "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.paymentRail": "AANI",
  "paymentResponse.status": "Rejected",
  "paymentResponse.paymentTransactionId": "OFP7d2c1e9a4b6f4c0e8a1b",
  "paymentResponse.rejectReasonCode": [
    {
      "code": "AANI.ScreeningRejected",
      "message": "Payment rejected by LFI screening controls."
    }
  ]
}

Response

Content-Type: application/json

A successful PATCH returns 204 No Content with no body. See the PATCH /payment-log/:id API Reference for the full schema.

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.2/payments/{PaymentId}.

Returns the current state of a payment your LFI created via POST/payments. The TPP polls this to observe screening outcomes, rail settlement, and any subsequent rejection.

Request headers

See Common request headers.

Path parameters

ParameterTypeDescription
paymentIdstringThe PaymentId your LFI returned from POST/payments

Response

Content-Type: application/json

The response shape mirrors the POST/payments 201 response — same data envelope, with the current Status, paymentTransactionId (once set), and any rejection details.

GET responsejson
{
  "data": {
    "id": "5ff155ea-853f-480c-ac74-1eaed7c1201f",
    "consentId": "cac2381a-7111-4c5f-bc2f-4319a93da7c5",
    "paymentTransactionId": "OFP7d2c1e9a4b6f4c0e8a1b",
    "status": "AcceptedSettlementCompleted",
    "statusUpdateDateTime": "2026-04-18T10:14:25Z",
    "creationDateTime": "2026-04-18T10:14:23Z",
    "instruction": {
      "Amount": { "amount": "100.00", "currency": "AED" }
    },
    "paymentPurposeCode": "ACM",
    "openFinanceBilling": { "Type": "Collection" }
  },
  "meta": {}
}

Behavioural rules

Per GET /payments/{paymentId} Requirements:

#Rule
1Sustain period — Serve GET/payments/{paymentId} for at least 1 year from the payment's creation date. Within this window, the response MUST reflect the current status, including any later screening, rail, or reversal outcomes
2Status consistency with the API Hub — The Status returned MUST exactly match the most recent value PATCHed to the API Hub Consent Manager via PATCH/payment-log/{id}. Any change in the LFI's systems MUST be reflected on both surfaces before it becomes observable to the TPP
3paymentTransactionId consistency — Once the LFI has PATCHed it to the Consent Manager, this endpoint MUST return the same value. Before then, omit the field entirely — do not return an empty string

Optional: answer from the API Hub payment log

Your LFI MAY build this response from the API Hub payment log instead of keeping its own copy of each payment. When your LFI returns the 201 on POST/payments, the Consent Manager stores its data object as the payment log entry's paymentResponse, and applies each PATCH/payment-log/{id} to it. The 201 and this endpoint share one schema, so the entry already holds every field this response needs, carrying the status your LFI last PATCHed.

A response built this way meets rules 2 and 3 by construction: status and paymentTransactionId are read from the record your PATCHes wrote. The API Hub keeps payment log entries well beyond the 1-year sustain period, so rule 1 is met without a copy of your own.

Steps

#Step
1Read the consent ID from the o3-consent-id request header, which the API Hub sends on every call to this endpoint
2Call GET/payment-log?consentId={consentId} on the Consent Manager, over the same connection your LFI uses for PATCH/payment-log/{id}. The response is paged, 25 entries per page unless you set pageSize. If the payment is not on the first page, request the next (page=2, and so on) until you find it or a page returns fewer entries than the page size
3Select the entry whose paymentId equals the paymentId path parameter. Do not rely on the order of the entries. If no entry matches, the payment does not exist under this consent: return 404 Resource.NotFound
4Build the response data from the entry, using the mapping below

If the Consent Manager cannot be reached or does not return 200, return 500 GenericRecoverableError.

Field mapping

Copy only the fields below. The response data object does not allow additional properties, and paymentResponse can hold keys that are not part of it (for example paymentRail, which every PATCH sets). Omit any optional field that is absent from the entry.

Response fieldPayment log entry field
data.idpaymentId
data.consentIdconsentId
data.statuspaymentResponse.status
data.statusUpdateDateTimepaymentResponse.statusUpdateDateTime
data.creationDateTimepaymentResponse.creationDateTime
data.paymentTransactionIdpaymentResponse.paymentTransactionId
data.rejectReasonCodepaymentResponse.rejectReasonCode (the same code / message items)
data.paymentPurposeCodepaymentResponse.paymentPurposeCode
Any other field your LFI returned on the 201, such as data.instruction, data.debtorReference or data.openFinanceBillingThe field of the same name in paymentResponse, unchanged
typescript
// Answer GET /payments/{paymentId} from the API Hub payment log.
// `cm.get` is your Consent Manager client: the same connection your LFI uses
// for PATCH /payment-log/{id}. It returns the parsed JSON body.

const PAGE_SIZE = 25

async function findLogEntry(consentId: string, paymentId: string) {
  for (let page = 1; ; page++) {
    const log = await cm.get(
      `/payment-log?consentId=${encodeURIComponent(consentId)}&page=${page}&pageSize=${PAGE_SIZE}`,
    )
    const entry = log.data.find((e) => e.paymentId === paymentId)
    if (entry) return entry
    if (log.data.length < PAGE_SIZE) return null
  }
}

// Fields copied from paymentResponse only when present, so an absent field is
// omitted rather than sent empty.
const OPTIONAL = ['paymentTransactionId', 'rejectReasonCode', 'instruction', 'debtorReference', 'openFinanceBilling']

async function getPayment(headers: Record<string, string>, paymentId: string) {
  const entry = await findLogEntry(headers['o3-consent-id'], paymentId)
  if (!entry) return null // 404 Resource.NotFound

  const p = entry.paymentResponse
  const data: Record<string, any> = {
    id: entry.paymentId,
    consentId: entry.consentId,
    status: p.status,
    statusUpdateDateTime: p.statusUpdateDateTime,
    creationDateTime: p.creationDateTime,
    paymentPurposeCode: p.paymentPurposeCode,
  }
  for (const key of OPTIONAL) if (p[key] !== undefined) data[key] = p[key]

  return { data, meta: {} }
}

Errors

StatuserrorCodeWhen to use
404Resource.NotFoundNo payment exists for the supplied paymentId (or the payment exists but belongs to a different consent)
403Consent.AccountTemporarilyBlocked / Consent.PermanentAccountAccessFailureThe debtor account has become inaccessible since the payment was created
500GenericRecoverableError / GenericErrorTransient or unrecoverable server error
504No body definedYou cannot complete processing within your internal time limit. The API Hub treats it as a timeout; the body for this status is not standardised

See the GET /payments/{paymentId} API Reference for the full schema.