🕒 15 minute read
Single Instant Payment - API Guide ​ v2.1
A Single Instant Payment is a one-time, immediate domestic payment initiated by the TPP on behalf of the user. The payment amount and destination are fixed at the point of consent — the user approves once, and the payment executes immediately after authorization.
Prerequisites ​
Before initiating a Single Instant 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 ​
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:
| 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. |
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 — exactly one entry
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 belowimport 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": [
{
"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 belowSee 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 encrypted PII is embedded as consent.PersonalIdentifiableInformation.
authorization_details ​
| Field | Type | Description | Example |
|---|---|---|---|
type* | enum | Must be urn:openfinanceuae:service-initiation-consent:v2.1 | urn:openfinanceuae:service-initiation-consent:v2.1 |
consent* | object | Consent properties agreed by the User with the TPP. Described below | — |
subscription | object | Optional subscription to Event Notifications via Webhook. Described below | — |
consent (Required) | authorization_details.consent ​
| Field | Type | Description | Example |
|---|---|---|---|
ConsentId* | string (uuid) | Unique ID assigned by the TPP (1–128 chars) | b8f42378-10ac-46a1-8d20-4e020484216d |
IsSingleAuthorization* | boolean | Whether the payment requires only one authorizing party | true |
ExpirationDateTime* | date-time | Consent expiry (ISO 8601 with timezone, max 1 year) | 2026-05-03T15:46:00+00:00 |
AuthorizationExpirationDateTime | date-time | Deadline 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-05-03T16:00:00+00:00 |
BaseConsentId | string (uuid) | Used when amending or renewing an existing consent | — |
Permissions | array<enum> | Optional access permissions granted alongside the payment consent | ReadAccountsBasic, ReadBalances |
ControlParameters* | object | Payment schedule and amount. Described below | — |
PersonalIdentifiableInformation* | string (JWE) | Encrypted creditor and risk data — the encryptedPII string from Step 1 | eyJhbGci... |
PaymentPurposeCode* | string (3 chars) | AANI payment purpose code | ACM |
DebtorReference | string | Reference shown on the debtor's statement | Test Purchase |
CreditorReference | string | Reference shown on the creditor's statement | Test Purchase |
ControlParameters.ConsentSchedule.SinglePayment (Required) ​
| Field | Type | Description | Example |
|---|---|---|---|
Type* | enum | Must be SingleInstantPayment | SingleInstantPayment |
Amount.Amount* | string | Payment amount (decimal, max 2 d.p.) | 100.00 |
Amount.Currency* | string | ISO 4217 currency code | AED |
Example request ​
"authorization_details": [
{
"type": "urn:openfinanceuae:service-initiation-consent:v2.1",
"consent": {
"ConsentId": "{{unique-guid}}",
"IsSingleAuthorization": true,
"ExpirationDateTime": "2026-05-03T15:46:00+00:00",
// Multi-authorization only: deadline for all authorizers to act.
// SHOULD NOT be set when IsSingleAuthorization is true.
// "AuthorizationExpirationDateTime": "2026-05-03T16:00:00+00:00",
"Permissions": [
"ReadAccountsBasic",
"ReadAccountsDetail",
"ReadBalances",
"ReadRefundAccount"
],
"ControlParameters": {
"ConsentSchedule": {
"SinglePayment": {
"Type": "SingleInstantPayment",
"Amount": {
"Amount": "100.00",
"Currency": "AED"
}
}
}
},
// Encrypted PII from Step 1
"PersonalIdentifiableInformation": "{{encryptedPII}}",
"PaymentPurposeCode": "ACM",
"DebtorReference": "Invoice 1234",
"CreditorReference": "Invoice 1234"
}
}
]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.
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() + 60 * 60 * 1000).toISOString(), // 1 hour
Permissions: ['ReadAccountsBasic', 'ReadAccountsDetail', 'ReadBalances', 'ReadRefundAccount'],
ControlParameters: {
ConsentSchedule: {
SinglePayment: {
Type: 'SingleInstantPayment',
Amount: { Amount: '100.00', Currency: 'AED' },
},
},
},
PersonalIdentifiableInformation: encryptedPII, // from Step 1
PaymentPurposeCode: 'ACM',
DebtorReference: 'Invoice 1234',
CreditorReference: 'Invoice 1234',
},
},
]
const requestJWT = await buildRequestJWT({
scope: 'payments openid',
codeChallenge,
authorizationDetails,
})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(hours=1)).isoformat(),
"Permissions": ["ReadAccountsBasic", "ReadAccountsDetail", "ReadBalances", "ReadRefundAccount"],
"ControlParameters": {
"ConsentSchedule": {
"SinglePayment": {
"Type": "SingleInstantPayment",
"Amount": {"Amount": "100.00", "Currency": "AED"},
}
}
},
"PersonalIdentifiableInformation": encrypted_pii, # from Step 1
"PaymentPurposeCode": "ACM",
"DebtorReference": "Invoice 1234",
"CreditorReference": "Invoice 1234",
},
}
]
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:
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(),
})
}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.
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()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.
| 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 |
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.
// 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)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_urlUser Experience
See User Experience for screen mockups of the Consent and Authorization pages the user sees at the bank, including an interactive form where you can edit the consent JSON and PII and preview the resulting UI.
After redirecting, the user will:
- Authenticate with their bank
- Review the payment details — amount, recipient, and purpose — on the bank's authorization screen
- Approve or decline
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.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}`)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) ​
// 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()# 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 minutesToken storage
Never store tokens in localStorage. Use httpOnly cookies or a server-side session store. See Tokens & Assertions for the full token lifecycle.
Creating the Payment ​
Step 9 - Encrypt PII for Payment Initiation ​
Before constructing the payment request, you must encrypt a fresh PII token specifically for the payment. This follows the Domestic Payment PII Schema Object (AEBankServiceInitiation.AEDomesticPaymentPIIProperties) — the same JWS-inside-JWE pattern used in Step 1, but submitted on the payment itself rather than on the /par consent.
Creditor must exactly match the consent PII
The Creditor object inside the payment PII — including CreditorAccount.SchemeName, CreditorAccount.Identification, CreditorAccount.Name, and any Creditor.Name or CreditorAgent fields — must be byte-for-byte identical to the Creditor you encrypted in Step 1. The LFI decrypts both PII tokens and compares them; any discrepancy can result in rejection.
Build the PII object according to the schema, then encrypt it using the same encryptPII helper from Step 1:
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": {
"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 belowimport 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": {
"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 belowSee 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 10 - Sign and Submit the Payment Request ​
Include x-fapi-interaction-id and x-idempotency-key. As 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.
The POST /payments body is sent as Content-Type: application/jwt — the payment payload is wrapped in a signed JWT (AEPaymentRequestSigned) using your private signing key. The LFI verifies the signature before processing the payment.
Every field in the request must exactly match the corresponding value from the authorized consent:
| Field | Must match |
|---|---|
ConsentId | The ConsentId from the authorized consent |
Instruction.Amount.Amount | consent.ControlParameters.ConsentSchedule.SinglePayment.Amount.Amount |
Instruction.Amount.Currency | consent.ControlParameters.ConsentSchedule.SinglePayment.Amount.Currency |
PaymentPurposeCode | consent.PaymentPurposeCode |
OpenFinanceBilling | consent.OpenFinanceBilling (including Type and, if present, MerchantId) |
DebtorReference | consent.DebtorReference |
CreditorReference | consent.CreditorReference |
import { SignJWT } from 'jose'
const LFI_API_BASE = process.env.LFI_API_BASE_URL!
// Build the payment payload — wrapped in `message` per AEPaymentRequestSigned
const paymentPayload = {
message: {
Data: {
ConsentId: consentId, // must exactly match the authorized consent
Instruction: {
Amount: {
Amount: '100.00', // must exactly match SinglePayment.Amount.Amount
Currency: 'AED', // must exactly match SinglePayment.Amount.Currency
},
},
PersonalIdentifiableInformation: paymentEncryptedPII, // from Step 9a
PaymentPurposeCode: 'ACM', // must exactly match consent.PaymentPurposeCode
DebtorReference: 'Invoice 1234', // must exactly match consent.DebtorReference
CreditorReference: 'Invoice 1234', // must exactly match consent.CreditorReference
OpenFinanceBilling: {
Type: 'PushP2P', // must exactly match consent.OpenFinanceBilling.Type
},
},
},
}
// Sign the payload as a JWT using your private signing key
// 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 ${access_token}`,
'Content-Type': 'application/jwt',
'x-idempotency-key': idempotencyKey, // stable per payment attempt; reuse on retry
'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()
// Store PaymentId to poll for statusimport uuid
import time
from jose import jwt as jose_jwt
LFI_API_BASE = os.environ["LFI_API_BASE_URL"]
# Build the payment payload — wrapped in `message` per AEPaymentRequestSigned
payment_payload = {
"message": {
"Data": {
"ConsentId": consent_id, # must exactly match the authorized consent
"Instruction": {
"Amount": {
"Amount": "100.00", # must exactly match SinglePayment.Amount.Amount
"Currency": "AED", # must exactly match SinglePayment.Amount.Currency
}
},
"PersonalIdentifiableInformation": payment_encrypted_pii, # from Step 9a
"PaymentPurposeCode": "ACM", # must exactly match consent.PaymentPurposeCode
"DebtorReference": "Invoice 1234", # must exactly match consent.DebtorReference
"CreditorReference": "Invoice 1234", # must exactly match consent.CreditorReference
"OpenFinanceBilling": {
"Type": "PushP2P", # must exactly match consent.OpenFinanceBilling.Type
},
}
}
}
# Sign the payload as a JWT using your private signing key
# 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"},
)
payment_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, # stable per payment attempt; reuse on retry
"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 = payment_response.json()["Data"]
payment_id = data["PaymentId"]
status = data["Status"]Consent replay validation
The payment request is validated at two points. The API Hub checks that ConsentId, Instruction.Amount, PaymentPurposeCode, DebtorReference, CreditorReference, and OpenFinanceBilling exactly match the authorized consent — a mismatch returns 400 before the request reaches the LFI. The LFI then decrypts the payment PII and verifies that all creditor fields match the PII from the consent. Either validation failure results in rejection.
A successful POST /payments ​
A 201 Created response is returned as a signed JWT (application/jwt). The verified JWT body is a message envelope wrapping Data and Links per AEPaymentIdResponseSigned.
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 |
{
"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"
}
}
}See the POST /payments API reference for the full request and response schema.
