TPP · Banking · Confirmation of Payee

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.

01 Prerequisites

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_jwt before calling the token endpoint.
02 API Sequence Flow

End-to-end Confirmation of Payee

Sequence diagramConfirmation of Payee API FlowClick to expand
03 Step 1 — Discover the LFI

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:

FieldDescription
DiscoveryEndpointUrlThe .well-known endpoint for the LFI's Authorisation Server. Fetch this to obtain the token_endpoint and issuer used in later steps.
ResourceServerUrlThe 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.

04 Step 2 — Build a Client Assertion

Prove your application's identity

Use the signJWT() helper to build a client assertion proving your application's identity:

typescript
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.

05 Step 3 — Token Request

Exchange the assertion for an access token

POST to any LFI's token endpoint with scope=confirmation-of-payee:

typescript
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()
06 Step 4 — Build a signed discovery request

Sign the IBAN-lookup payload

The request body is a signed JWT containing the IBAN, signed with your signing key:

typescript
// 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
    },
  },
})
07 Step 5 — POST /discovery

Resolve the LFI for an IBAN

POST/discovery

Include x-fapi-interaction-id on the request. See Request Headers.

typescript
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.

08 Step 6 — Resolve the LFI token endpoint

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:

typescript
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
09 Step 7 — Build a Client Assertion

Build a fresh assertion for the resolved LFI

Use the signJWT() helper to build a client assertion proving your application's identity:

typescript
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.

10 Step 8 — Token Request

Get an access token from the resolved LFI

POST to the token endpoint (resolved in Step 6) with scope=confirmation-of-payee:

typescript
// 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()
11 Step 9 — Build and Sign the Confirmation Request

POST /confirmation — request payload

POST/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

FieldTypeDescriptionExample
Data.SchemeName*enumAccount identifier type — always IBANIBAN
Data.Identification*stringThe IBAN to verifyAE070331234567890123456
Data.Name.FullName*stringFull name of the account holderIbrahim Al Suwaidi
Data.Name.GivenNamestringGiven (first) name — individual accountsIbrahim
Data.Name.LastNamestringFamily name — individual accountsAl Suwaidi
Data.Name.BusinessNamestringRegistered business name — use instead of personal name fields for business accountsBusiness 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)

message claimjson
{
  "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:

typescript
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',
      },
    },
  },
})
12 Step 10 — POST /confirmation

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.

typescript
// 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:

FieldTypeDescription
Data.NameMatchIndicatorstringThe result of the name match check — see enum below
Data.MaskedNamestringThe account holder's name, partially masked. Returned on ConfirmationOfPayee.Partial and ConfirmationOfPayee.No
NameMatchIndicatorMeaning
ConfirmationOfPayee.YesName and account match — safe to proceed
ConfirmationOfPayee.PartialName partially matches — present the MaskedName to the payer
ConfirmationOfPayee.NoName 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 .:

JWS compact form
<header>.<payload>.<signature>

Verify the signature using the LFI's public key, then base64url-decode the payload:

typescript
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:

decoded payloadjson
{
  "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.