🕒 7 minute read
Confirmation of Payee - API Guide ​ v2.1
Confirmation of Payee (CoP) lets a TPP verify that an IBAN belongs to the named individual or business before initiating a payment. Unlike payment flows, CoP does not require user authorization — the TPP authenticates directly using a client credentials grant and the LFI responds with a match result in seconds.
CoP is served by each participating LFI independently. Before calling an LFI directly, the TPP first calls the API Hub's discovery endpoint to identify which LFI holds the destination account and retrieve its endpoint URLs.
Prerequisites ​
Before calling the CoP API, 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 the confirmation request JWT and client assertions.
Registration with the relevant Authorisation Server The application must be registered with the Authorisation Server of the LFI that holds the destination account.
Understanding of Tokens & Assertions You should understand how client authentication works with
private_key_jwtbefore calling the token endpoint.
API Sequence Flow ​
Step 1 — Discover the LFI ​
CoP is served by individual LFIs — the /discovery endpoint resolves a payee IBAN to the correct LFI and returns two URLs you will need for the rest of the flow:
| Field | Description |
|---|---|
DiscoveryEndpointUrl | The .well-known endpoint for the LFI's Authorisation Server. Fetch this to obtain the token_endpoint and issuer used in later steps. |
ResourceServerUrl | The base URL of the LFI's Resource Server. Use this as the base URL when calling /confirmation. |
Before calling /discovery you must obtain an access token from any LFI you are registered with using a client credentials grant. The API Hub does not make any requests to the LFI when processing /discovery — it resolves the IBAN centrally — so the response is the same regardless of which LFI you authenticate with. You only need to perform discovery once, and the POST /discovery request must be sent to the LFI whose token you are using.
Step 2 — Build 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.ISSUER! // from the LFI's .well-known/openid-configuration
const clientAssertion = await signJWT({
iss: CLIENT_ID,
sub: CLIENT_ID,
aud: ISSUER,
jti: crypto.randomUUID(),
})import os, uuid
from sign_jwt import sign_jwt
CLIENT_ID = os.environ["CLIENT_ID"]
ISSUER = os.environ["ISSUER"] # from the LFI's .well-known/openid-configuration
client_assertion = sign_jwt({
"iss": CLIENT_ID,
"sub": CLIENT_ID,
"aud": ISSUER,
"jti": str(uuid.uuid4()),
})See Client Assertion for the full claims reference.
Step 3 — Token Request ​
POST to any LFI's token endpoint with scope=confirmation-of-payee:
const TOKEN_ENDPOINT = process.env.TOKEN_ENDPOINT! // from the LFI's .well-known/openid-configuration
const params = new URLSearchParams({
grant_type: 'client_credentials',
scope: 'confirmation-of-payee',
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: clientAssertion,
})
const tokenResponse = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
// agent: new https.Agent({ cert: transportCert, key: transportKey }),
})
const { access_token: accessToken } = await tokenResponse.json()import httpx, os
TOKEN_ENDPOINT = os.environ["TOKEN_ENDPOINT"] # from the LFI's .well-known/openid-configuration
token_response = httpx.post(
TOKEN_ENDPOINT,
data={
"grant_type": "client_credentials",
"scope": "confirmation-of-payee",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": client_assertion,
},
# cert=("transport.crt", "transport.key"),
)
access_token = token_response.json()["access_token"]Step 4 — Build a signed discovery request ​
The request body is a signed JWT containing the IBAN, signed with your signing key:
// CLIENT_ID and ISSUER already set in Step 2
const discoveryRequest = await signJWT({
iss: CLIENT_ID,
aud: ISSUER,
jti: crypto.randomUUID(),
message: {
Data: {
SchemeName: 'IBAN',
Identification: 'AE070331234567890123456', // IBAN to check
},
},
})# CLIENT_ID and ISSUER already set in Step 2
discovery_request = sign_jwt({
"iss": CLIENT_ID,
"aud": ISSUER,
"jti": str(uuid.uuid4()),
"message": {
"Data": {
"SchemeName": "IBAN",
"Identification": "AE070331234567890123456", # IBAN to check
}
},
})Step 5 — POST /discovery ​
Include x-fapi-interaction-id on the request. See Request Headers.
const LFI_BASE_URL = process.env.LFI_BASE_URL! // base URL of the LFI you authenticated with in Step 3
// accessToken obtained in Step 3
const discoveryResponse = await fetch(
`${LFI_BASE_URL}/open-finance/confirmation-of-payee/v2.1/discovery`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/jwt',
'Accept': 'application/jwt',
'x-fapi-interaction-id': crypto.randomUUID(),
},
body: discoveryRequest,
// agent: new https.Agent({ cert: transportCert, key: transportKey }),
}
)
// Response is a signed JWT — decode the payload to read the result
const discoveryJwt = await discoveryResponse.text()
const [, discoveryB64] = discoveryJwt.split('.')
const { message } = JSON.parse(Buffer.from(discoveryB64, 'base64url').toString())
const { DiscoveryEndpointUrl, ResourceServerUrl } = message.Dataimport httpx, base64, json, os
LFI_BASE_URL = os.environ["LFI_BASE_URL"] # base URL of the LFI you authenticated with in Step 3
# access_token obtained in Step 3
discovery_response = httpx.post(
f"{LFI_BASE_URL}/open-finance/confirmation-of-payee/v2.1/discovery",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/jwt",
"Accept": "application/jwt",
"x-fapi-interaction-id": str(uuid.uuid4()),
},
content=discovery_request,
# cert=("transport.crt", "transport.key"),
)
# Response is a signed JWT — decode the payload to read the result
discovery_jwt = discovery_response.text
payload_b64 = discovery_jwt.split(".")[1]
message = json.loads(base64.urlsafe_b64decode(payload_b64 + "=="))["message"]
discovery_endpoint_url = message["Data"]["DiscoveryEndpointUrl"]
resource_server_url = message["Data"]["ResourceServerUrl"]See the POST /discovery API reference for the full request and response schema.
Step 6 — Resolve the LFI token endpoint ​
Fetch the DiscoveryEndpointUrl directly to read the LFI's OpenID configuration. This gives you the token_endpoint and issuer needed for the next steps:
const oidcConfig = await fetch(DiscoveryEndpointUrl).then(r => r.json())
const tokenEndpoint = oidcConfig.token_endpoint // used in Step 8
const issuer = oidcConfig.issuer // used in Step 7oidc_config = httpx.get(discovery_endpoint_url).json()
token_endpoint = oidc_config["token_endpoint"] # used in Step 8
issuer = oidc_config["issuer"] # used in Step 7Step 7 — Build 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!
// issuer resolved from DiscoveryEndpointUrl in Step 6
const clientAssertion = await signJWT({
iss: CLIENT_ID,
sub: CLIENT_ID,
aud: issuer,
jti: crypto.randomUUID(),
})import os, uuid
from sign_jwt import sign_jwt
CLIENT_ID = os.environ["CLIENT_ID"]
# issuer resolved from discovery_endpoint_url in Step 6
client_assertion = sign_jwt({
"iss": CLIENT_ID,
"sub": CLIENT_ID,
"aud": issuer,
"jti": str(uuid.uuid4()),
})See Client Assertion for the full claims reference.
Step 8 — Token Request ​
POST to the the token endpoint (resolved in Step 6) with scope=confirmation-of-payee:
// tokenEndpoint resolved from DiscoveryEndpointUrl in Step 6
const params = new URLSearchParams({
grant_type: 'client_credentials',
scope: 'confirmation-of-payee',
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
client_assertion: clientAssertion,
})
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
// agent: new https.Agent({ cert: transportCert, key: transportKey }),
})
const { access_token } = await tokenResponse.json()import httpx
# token_endpoint resolved from discovery_endpoint_url in Step 6
token_response = httpx.post(
token_endpoint,
data={
"grant_type": "client_credentials",
"scope": "confirmation-of-payee",
"client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
"client_assertion": client_assertion,
},
# cert=("transport.crt", "transport.key"),
)
access_token = token_response.json()["access_token"]POST /open-finance/confirmation-of-payee/v2.1/confirmation ​
Step 9 — Build and Sign the Confirmation Request ​
The confirmation request is sent as a signed JWT (Content-Type: application/jwt). Build the JWT payload containing the account details you want to verify, then sign it with your signing key.
Request payload fields ​
| Field | Type | Description | Example |
|---|---|---|---|
Data.SchemeName* | enum | Account identifier type — always IBAN | IBAN |
Data.Identification* | string | The IBAN to verify | AE070331234567890123456 |
Data.Name.FullName* | string | Full name of the account holder | Ibrahim Al Suwaidi |
Data.Name.GivenName | string | Given (first) name — individual accounts | Ibrahim |
Data.Name.LastName | string | Family name — individual accounts | Al Suwaidi |
Data.Name.BusinessName | string | Registered business name — use instead of personal name fields for business accounts | Business Inc. |
Individual vs. Business
Provide GivenName + LastName for personal accounts, or BusinessName for business accounts. Do not mix both.
Example payload (inside the JWT message claim) ​
{
"Data": {
"SchemeName": "IBAN",
"Identification": "AE070331234567890123456",
"Name": {
"FullName": "Ibrahim Al Suwaidi",
"GivenName": "Ibrahim",
"LastName": "Al Suwaidi"
}
}
}Signing the request ​
Use the signJWT() helper, wrapping the payload in a message claim:
import crypto from 'node:crypto'
import { signJWT } from './sign-jwt'
const signedRequest = await signJWT({
iss: CLIENT_ID,
aud: issuer,
jti: crypto.randomUUID(),
message: {
Data: {
SchemeName: 'IBAN',
Identification: 'AE070331234567890123456',
Name: {
FullName: 'Ibrahim Al Suwaidi',
GivenName: 'Ibrahim',
LastName: 'Al Suwaidi',
},
},
},
})import uuid
from sign_jwt import sign_jwt
signed_request = sign_jwt({
"iss": CLIENT_ID,
"aud": issuer,
"jti": str(uuid.uuid4()),
"message": {
"Data": {
"SchemeName": "IBAN",
"Identification": "AE070331234567890123456",
"Name": {
"FullName": "Ibrahim Al Suwaidi",
"GivenName": "Ibrahim",
"LastName": "Al Suwaidi",
},
}
},
})Step 10 — POST /confirmation ​
Send the signed JWT to the LFI's CoP endpoint using the ResourceServerUrl resolved in Step 5. Both the request body and the response are JWTs. Include x-fapi-interaction-id on every request. See Request Headers.
// ResourceServerUrl resolved from discovery in Step 5
const copResponse = await fetch(
`${ResourceServerUrl}/open-finance/confirmation-of-payee/v2.1/confirmation`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${access_token}`,
'Content-Type': 'application/jwt',
'Accept': 'application/jwt',
'x-fapi-interaction-id': crypto.randomUUID(),
},
body: signedRequest,
// agent: new https.Agent({ cert: transportCert, key: transportKey }),
}
)
// Response is a signed JWT — decode the payload to read the result
const responseJwt = await copResponse.text()
const [, payloadB64] = responseJwt.split('.')
const result = JSON.parse(Buffer.from(payloadB64, 'base64url').toString())import httpx, base64, json
# resource_server_url resolved from discovery in Step 5
cop_response = httpx.post(
f"{resource_server_url}/open-finance/confirmation-of-payee/v2.1/confirmation",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/jwt",
"Accept": "application/jwt",
"x-fapi-interaction-id": str(uuid.uuid4()),
},
content=signed_request,
# cert=("transport.crt", "transport.key"),
)
# Response is a signed JWT — decode the payload to read the result
response_jwt = cop_response.text
payload_b64 = response_jwt.split(".")[1]
result = json.loads(base64.urlsafe_b64decode(payload_b64 + "=="))Response ​
The response is a signed JWT. Decode the payload to read the match result:
| Field | Type | Description |
|---|---|---|
Data.NameMatchIndicator | string | The result of the name match check — see enum below |
Data.MaskedName | string | The account holder's name, partially masked. Returned on ConfirmationOfPayee.Partial and ConfirmationOfPayee.No |
NameMatchIndicator | Meaning |
|---|---|
ConfirmationOfPayee.Yes | Name and account match — safe to proceed |
ConfirmationOfPayee.Partial | Name partially matches — present the MaskedName to the payer |
ConfirmationOfPayee.No | Name does not match — present the MaskedName to the payer |
Proceed with caution on non-Yes results
A ConfirmationOfPayee.Partial or ConfirmationOfPayee.No result must be surfaced to the payer — along with the MaskedName — before initiating a payment. Proceeding without informing the user may increase the risk of authorised push payment fraud.
Decoding the JWS ​
The /confirmation response body is a compact JWS — three base64url-encoded segments separated by .:
<header>.<payload>.<signature>Verify the signature using the LFI's public key, then base64url-decode the payload:
function decodeJwsPayload(jws: string) {
const [, payloadB64] = jws.split('.')
const json = atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/'))
return JSON.parse(json)
}The decoded payload contains a message object with the CoP result under message.Data:
{
"iss": "https://rs1.altareq1.sandbox.apihub.openfinance.ae",
"aud": ["https://tpp.example.com"],
"iat": 1713196200,
"nbf": 1713196200,
"exp": 1713196500,
"message": {
"Data": {
"NameMatchIndicator": "ConfirmationOfPayee.Partial",
"MaskedName": "Ibrahim Al S*****"
},
"Links": {
"Self": "https://rs1.altareq1.sandbox.apihub.openfinance.ae/open-finance/confirmation-of-payee/v2.1/confirmation"
},
"Meta": {}
}
}See the POST /confirmation API reference for the full request and response schema.
Using the CoP Response in a Payment Consent ​
Where Confirmation of Payee has been performed for a creditor, include the full raw JWS response string returned by the /confirmation endpoint in the ConfirmationOfPayeeResponse field of the creditor entry inside the payment consent PII.
{
"Initiation": {
"Creditor": [
{
"Creditor": { "Name": "Ibrahim Al Suwaidi" },
"CreditorAccount": {
"SchemeName": "IBAN",
"Identification": "AE070331234567890123456",
"Name": { "en": "Ibrahim Al Suwaidi" }
},
"ConfirmationOfPayeeResponse": "eyJhbGci..." // full JWS string from Step 10
}
]
}
}This gives the LFI confidence that the creditor account details have been verified before the payment consent was created. The value must be the complete compact JWS string — do not decode it to an object before embedding.
See Creditor for the full PII creditor schema and the creditor models (single, multiple, and open beneficiary).
See Confirmation of Payee — User Experience for consent and authorisation page examples across different match results.
