# Identity verification

> [!NOTE]
> This API is under active development and may change in future releases.

PayoutID also offers identity verification and AML checks. You create an invitation and send the user to the page it returns; PayoutID collects the user's data and documents and then sends the user back to you. Because some checks finish asynchronously, the results arrive as webhooks.

![Payout ID verification diagram](https://developers.payout.tech/_media/payout-id-verification.png)

1. **Get an access token.** Use the [client credentials grant](https://developers.payout.tech/guides/payout-id-oauth.html#client-credentials-grant) at [Retrieve token](https://developers.payout.tech/api/payout-id.html#endpoint_to_retrieve_authorization_token) with the scopes you need, see [Scopes](#scopes).
2. **Create an invitation.** Call [Create invitation](https://developers.payout.tech/api/payout-id.html#create_invitation) with your `callback_url`, `notify_url` and, for AML checks, `aml_notify_url`. The response contains the invitation `id` and a `redirect_url`.
3. **Redirect the user.** Send the user's browser to `redirect_url`. When the user has finished, PayoutID sends them to your `callback_url`.
4. **Wait for the webhooks.** The results arrive at `notify_url` and `aml_notify_url`, see [Webhooks](#webhooks).

## Scopes

| Scope | Required |
| --- | --- |
| `verify` | Always |
| `account_info` | When the invitation requests the user's bank account details (`bank_account_requested: true`) |
| `aml` | When the invitation requests AML checks (`aml_requested: true`) |

Without a required scope, the invitation is refused with HTTP `422`.

## Webhooks

There are two webhooks, both sent with `POST`:

1. **Identity verification webhook**, sent to `notify_url`.
2. **AML check webhook**, sent to `aml_notify_url`.

Both are signed. Verify the signature of every webhook, see [Signature](#signature), to reject forged calls.

### Identity verification webhook

Contains the result of the identity verification and, if requested, the user's bank account details. Every attribute in `data` is always present; a value that is not available is `null`.

| Path | Type | Example | Description |
| --- | --- | --- | --- |
| `type` | string | `IDENTITY_CHECK` | Webhook type. Not present when `data.identity_verification_failed` is `true` |
| `data` | object | | The verification data |
| `data.id` | string | `edf29e60-45a5-463b-b120-033e835df3ce` | ID of the invitation, as returned when you created it |
| `data.provided_email` | string | `john.doe@example.com` | E-mail address provided by the user |
| `data.provided_name` | string | `John` | First name provided by the user |
| `data.provided_surname` | string | `Doe` | Last name provided by the user |
| `data.provided_is_sanctioned` | boolean | `false` | The user's answer to whether they are on a sanctions list |
| `data.provided_is_pep` | boolean | `false` | The user's answer to whether they are a politically exposed person |
| `data.identity_verification_failed` | boolean | `false` | `true` when the identity verification failed |
| `data.bank_account_requested` | boolean | `false` | The invitation requested the owner of the user's bank account |
| `data.bank_account_unsupported_integration` | boolean | `false` | The user said their bank is not among the supported banks |
| `data.bank_account_owner_name` | string or null | `John Doe` | Account owner name retrieved from the bank; several owners are separated by `, `. `null` if it could not be retrieved or was not requested |
| `data.bank_account_iban` | string or null | `SK3112000000198742637541` | IBAN retrieved from the bank. `null` if the user did not give access or it was not requested |
| `data.overall` | string | `APPROVED` | Final result of the verification |
| `data.finish_time` | string | `2026-03-12T13:16:44Z` | When the verification finished |
| `data.document_type` | string | `passport` | Document the user verified with: `identity-card` or `passport` |
| `data.document_number` | string | `AB1234567` | Number of the document |
| `data.document_valid_until` | string | `2030-03-09` | Expiry date of the document |
| `data.document_first_name` | string | `JOHN` | First name read from the document |
| `data.document_last_name` | string | `DOE` | Last name read from the document |
| `data.document_sex` | string | `M` | Sex read from the document |
| `data.document_nationality` | string | `SVK` | Nationality read from the document (ISO 3166-1 alpha-3) |
| `data.document_issuing_country` | string | `SVK` | Issuing country of the document (ISO 3166-1 alpha-3) |
| `data.document_birth_place` | string | `BRATISLAVA` | Place of birth read from the document |
| `data.document_personal_code` | string | `800101/1234` | Personal identification number read from the document |
| `data.document_date_of_birth` | string | `1980-01-01` | Date of birth read from the document |
| `data.document_front_url` | string | | Download URL of the front of the document. Expires after one hour |
| `data.document_back_url` | string | | Download URL of the back of the document. Expires after one hour |
| `data.photo_face_url` | string | | Download URL of the user's face photo. Expires after one hour |
| `data.platform`, `data.start_time`, `data.client_ip`, `data.client_ip_country`, `data.client_location`, `data.suspicion_reasons`, `data.mismatch_tags`, `data.fraud_tags`, `data.auto_document`, `data.auto_face`, `data.manual_document`, `data.manual_face`, `data.aditional_steps` | | | Currently not filled: always `null`. They are part of the signature |
| `signature` | string | | Signature of the webhook, see [Signature](#signature) |
| `nonce` | string | | Value used in the signature |

When a webhook is retried, the three download URLs are `null`.

When `identity_verification_failed` is `true`, `data` contains only `id`, the `provided_*` attributes, the `bank_account_*` attributes and `identity_verification_failed`.

### AML check webhook

Contains the results of the AML check. It is sent to `aml_notify_url`. As the monitoring continues, one invitation can receive several AML check webhooks.

| Path | Type | Example | Description |
| --- | --- | --- | --- |
| `type` | string | `AML_CHECK` | Webhook type |
| `data` | object | | The check data |
| `data.id` | string | `edf29e60-45a5-463b-b120-033e835df3ce` | ID of the invitation, as returned when you created it |
| `data.status_service_suspected` | boolean | `true` | At least one hit is not a false positive |
| `data.status_service_used` | boolean | `true` | The screening service was used |
| `data.status_service_found` | boolean | `true` | The screening returned one or more hits |
| `data.status_check_successful` | boolean | `true` | The check completed |
| `data.status_overall` | string | `SUSPECTED` | Overall result: `NOT_SUSPECTED`, `SUSPECTED` or `PENDING` |
| `data.error_message` | string or null | | Error description, if any |
| `data.uid` | string | `FAFQLS95W7Z0JI9HL13VZBMX6` | ID of the check |
| `data.aml_items` | array of [AML items](#aml-item) | | The hits. Not part of the signature, so an item may gain attributes without changing how the signature is verified |
| `signature` | string | | Signature of the webhook, see [Signature](#signature) |
| `nonce` | string | | Value used in the signature |

#### AML item

Any attribute except `is_active` and `false_positive` can be `null`.

| Path | Type | Example | Description |
| --- | --- | --- | --- |
| `name` | string | `JOHN` | First name on the list |
| `surname` | string | `DOE` | Last name on the list |
| `reason` | string | `BELARUS PROGRAM` | Reason for the listing |
| `nationality` | string | `BELARUS` | Nationality on the list |
| `dob` | string | `31 AUG 1954` | Date of birth on the list, in the list's format |
| `suspicion` | string | `Sanctions` | Kind of hit, such as `PEP`, `Sanctions` or `Adverse Media`. Several kinds are separated by `; ` |
| `list_number` | string | | Number of the list entry |
| `list_name` | string | `OFAC` | Name of the list |
| `score` | integer | `100` | Match score |
| `last_update` | string | `2022-03-16` | Date of the last update of the entry |
| `is_person` | boolean | `true` | The entry is a person |
| `is_active` | boolean | `true` | The entry is still in force |
| `false_positive` | boolean | `false` | The hit was dismissed as not a real match |
| `linked_document` | string | | URL of the list entry |
| `other_information` | string | | Additional information |
| `checked_at` | string | | When the hit was checked |

### Examples

All examples on this page are signed with the client secret `c57f41ac-3bfb-4bb5-b18f-00cca093d97b`, so you can use them to test your signature check.

Successful identity verification:

```json
{
  "type": "IDENTITY_CHECK",
  "data": {
    "platform": null,
    "start_time": null,
    "finish_time": "2026-03-12T13:16:44Z",
    "client_ip": null,
    "client_ip_country": null,
    "client_location": null,
    "overall": "APPROVED",
    "suspicion_reasons": null,
    "mismatch_tags": null,
    "fraud_tags": null,
    "auto_document": null,
    "auto_face": null,
    "manual_document": null,
    "manual_face": null,
    "aditional_steps": null,
    "document_valid_until": "2030-03-09",
    "document_type": "passport",
    "document_number": "AB1234567",
    "document_front_url": "https://storage.example.com/identity_verifications/13b0f350/document_front.png?X-Amz-Expires=3600&X-Amz-Signature=…",
    "document_back_url": "https://storage.example.com/identity_verifications/13b0f350/document_back.png?X-Amz-Expires=3600&X-Amz-Signature=…",
    "document_first_name": "JOHN",
    "document_last_name": "DOE",
    "document_sex": "M",
    "document_nationality": "SVK",
    "document_issuing_country": "SVK",
    "document_birth_place": "BRATISLAVA",
    "document_personal_code": "800101/1234",
    "document_date_of_birth": "1980-01-01",
    "photo_face_url": "https://storage.example.com/identity_verifications/13b0f350/photo_face.png?X-Amz-Expires=3600&X-Amz-Signature=…",
    "id": "13b0f350-e208-4440-8f7c-cdae9d597f6d",
    "provided_email": "john.doe@example.com",
    "provided_name": "John",
    "provided_surname": "Doe",
    "provided_is_pep": false,
    "provided_is_sanctioned": false,
    "bank_account_requested": false,
    "bank_account_unsupported_integration": false,
    "bank_account_owner_name": null,
    "bank_account_iban": null,
    "identity_verification_failed": false
  },
  "nonce": "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5",
  "signature": "9f0a148105490db92a8e6755e7c79bd59dcc08c7a5311577b9ff2b7fcc83547c"
}
```

Failed identity verification:

```json
{
  "data": {
    "id": "ac286ff1-4e3c-41c7-ab00-127605583d36",
    "provided_email": "john.doe@example.com",
    "provided_name": "John",
    "provided_surname": "Doe",
    "provided_is_pep": false,
    "provided_is_sanctioned": false,
    "bank_account_requested": false,
    "bank_account_unsupported_integration": false,
    "bank_account_owner_name": null,
    "bank_account_iban": null,
    "identity_verification_failed": true
  },
  "nonce": "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5",
  "signature": "5f94a97a92ff99f2f20dd25d3d867187c03768316cd12fe859ea7908111e5bf2"
}
```

With bank account details (`bank_account_requested: true`), the same webhook also carries the owner and the IBAN:

```json
{
  "bank_account_requested": true,
  "bank_account_unsupported_integration": false,
  "bank_account_owner_name": "John Doe",
  "bank_account_iban": "SK3112000000198742637541"
}
```

When the user says their bank is not supported, `bank_account_unsupported_integration` is `true` and both bank account attributes are `null`:

```json
{
  "bank_account_requested": true,
  "bank_account_unsupported_integration": true,
  "bank_account_owner_name": null,
  "bank_account_iban": null
}
```

AML check:

```json
{
  "type": "AML_CHECK",
  "data": {
    "id": "b93e7497-1726-4216-8516-df678d86ca03",
    "status_service_suspected": true,
    "status_service_used": true,
    "status_service_found": true,
    "status_check_successful": true,
    "status_overall": "SUSPECTED",
    "error_message": null,
    "uid": "FAFQLS95W7Z0JI9HL13VZBMX6",
    "aml_items": [
      {
        "name": "JOHN",
        "surname": "DOE",
        "reason": "BELARUS PROGRAM",
        "nationality": "BELARUS",
        "dob": "31 AUG 1954",
        "suspicion": "Sanctions",
        "list_number": null,
        "list_name": "OFAC",
        "score": 100,
        "last_update": "2022-03-16",
        "is_person": true,
        "is_active": true,
        "false_positive": false,
        "linked_document": "https://sanctionssearch.ofac.treas.gov/Details.aspx?id=9760",
        "other_information": null,
        "checked_at": null
      },
      {
        "name": "JOHN",
        "surname": "DOE",
        "reason": "EU.5971.83",
        "nationality": "BELARUS",
        "dob": "1954",
        "suspicion": "Sanctions",
        "list_number": null,
        "list_name": "EU",
        "score": 87,
        "last_update": "2022-07-08",
        "is_person": true,
        "is_active": true,
        "false_positive": true,
        "linked_document": null,
        "other_information": null,
        "checked_at": null
      }
    ]
  },
  "nonce": "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5",
  "signature": "c3801f133b53ac713f081fa1e98e6690406405f8a7d63185c8c80f5ca0b2de36"
}
```

### Signature

Build the string to sign from the `data` values in the order given below, then the `nonce`, then your client secret, separated by `|`:

- a string or number is used as it is;
- `true` and `false` become `true` and `false`;
- `null` and an empty array `[]` become an empty string;
- an array is concatenated without a separator: `["test", "value"]` becomes `testvalue`.

Hash the string with SHA-256 and encode the hash as lowercase hex. The result must equal `signature`. `type` is not signed.

For example, take this imaginary webhook with the attributes `first_name`, `last_name`, `children`, `married`, `age` and `hobbies`:

```json
{
  "data": {"first_name": "John", "last_name": "Doe", "children": [], "married": true, "age": null, "hobbies": ["guitar", "skiing"]},
  "nonce": "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5",
  "signature": "bb165278ad8e9504649ac4cde2b3293f099523682d7e1dee38464b12e57a42e7"
}
```

With the client secret `c57f41ac-3bfb-4bb5-b18f-00cca093d97b`, the string to sign is `$first_name|$last_name|$children|$married|$age|$hobbies|$nonce|$client_secret`:

```text
John|Doe||true||guitarskiing|YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXowMTIzNDU2Nzg5|c57f41ac-3bfb-4bb5-b18f-00cca093d97b
```

Its SHA-256 as lowercase hex is the signature:

```text
bb165278ad8e9504649ac4cde2b3293f099523682d7e1dee38464b12e57a42e7
```

#### Order of the identity verification webhook

1. `platform`
2. `start_time`
3. `finish_time`
4. `client_ip`
5. `client_ip_country`
6. `client_location`
7. `overall`
8. `suspicion_reasons`
9. `mismatch_tags`
10. `fraud_tags`
11. `auto_document`
12. `auto_face`
13. `manual_document`
14. `manual_face`
15. `aditional_steps`
16. `document_valid_until`
17. `document_type`
18. `document_number`
19. `document_front_url`
20. `document_back_url`
21. `document_first_name`
22. `document_last_name`
23. `document_sex`
24. `document_nationality`
25. `document_issuing_country`
26. `document_birth_place`
27. `document_personal_code`
28. `document_date_of_birth`
29. `photo_face_url`
30. `id`
31. `provided_email`
32. `provided_name`
33. `provided_surname`
34. `provided_is_pep`
35. `provided_is_sanctioned`
36. `bank_account_requested`
37. `bank_account_unsupported_integration`
38. `bank_account_owner_name`
39. `bank_account_iban`
40. `identity_verification_failed`

When `identity_verification_failed` is `true`, the document attributes are left out and only these are signed:

1. `id`
2. `provided_email`
3. `provided_name`
4. `provided_surname`
5. `provided_is_pep`
6. `provided_is_sanctioned`
7. `bank_account_requested`
8. `bank_account_unsupported_integration`
9. `bank_account_owner_name`
10. `bank_account_iban`
11. `identity_verification_failed`

#### Order of the AML check webhook

1. `id`
2. `status_service_suspected`
3. `status_service_used`
4. `status_service_found`
5. `status_check_successful`
6. `status_overall`
7. `error_message`
8. `uid`

## Testing

The sandbox cannot process real banking data, so it uses a mock bank. The account owner name is always `John Doe`, and the IBAN depends on the bank the user selects:

| Bank | IBAN |
| --- | --- |
| UniCredit | `SK5111110000000002314003` |
| Prima banka | Two accounts to choose from: `SK0431000000002333363431` and `SK1531000000003150217305` |
| Any other bank | `SK0431000000002333363431` |

If the invitation or the user specifies an IBAN that is not one of these, no owner name is returned.
