Confirmation of Payee — API Guide 4 min read
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.
What you need before calling the CoP API
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 API Hub (Authorisation Server) — the application must be registered with the API Hub (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.
End-to-end Confirmation of Payee
Resolve the IBAN to a specific 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 API Hub 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.
Prove your application's identity
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(),
})
See Client Assertion for the full claims reference.
Exchange the assertion for an access token
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()
Sign the IBAN-lookup payload
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
},
},
})
Resolve the LFI for an IBAN
/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.Data
See the POST /discovery API reference for the full request and response schema.
Read the LFI's OpenID configuration
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 7
Build a fresh assertion for the resolved LFI
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(),
})
See Client Assertion for the full claims reference.
Get an access token from the resolved LFI
POST to 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()
POST /confirmation — request payload
/open-finance/confirmation-of-payee/v2.1/confirmation 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. |
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',
},
},
},
})
Send the signed JWT and decode the response
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())
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 |
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.
