---
title: "Verify a cardholder with your own ID capture"
description: "Upload a cardholder's ID and selfie from your own app, submit them for review, then finish the one liveness step cards need."
---
This recipe uploads a cardholder's ID and selfie from your own app, submits them for review, and then finishes the one liveness step that cards need.

**Who it is for.** A neobank that already photographs the customer's ID card and selfie in its own app, and does not want to send people to a hosted page for documents.

**Status.** Available.

## Money example

1. You upload Chidi's ID front, ID back and selfie, and submit them. No money moves.
2. Verification completes. The activation response asks for a liveness check, and Chidi finishes it on his phone.
3. You activate again, and Chidi gets a virtual card with a 200 USD daily limit. You top it up with 50 USD.

## Before you start

- You need an active card account, the permissions and an `individual` customer, as listed in [Issue a virtual card after a hosted check](/recipes/cards/issue-a-virtual-card-after-a-hosted-check#before-you-start).
- Files are JPEG, PNG or PDF, and the selfie must be an image. Each file is under 5 MB before base64, or under 500 KB in sandbox. File names end in `.jpg`, `.jpeg`, `.png` or `.pdf`.
- Gravv does not deduplicate a request body over 1 MiB under its `Idempotency-Key`. A retried upload of a file over about 770 KB runs again instead of returning the first response. The repeated upload replaces the earlier file of the same type and side.
- Send countries as **3-letter** codes (`NGA`), including on the selfie.
- Use one upload flow per person. Documents uploaded through `/v1/customers/kyc/*` use other type names.

## Find out what is required

A person needs a `selfie` plus one of `passport`, `id_card` or `drivers_license`. The submit call names anything missing. It returns HTTP 200 with `data.code` `MISSING_DOCUMENTS` and `details.missing_documents`.

## Steps

### 1. Upload each document

Send one call per image, each with a new `Idempotency-Key`. `content` is plain standard base64 of the file.

<CodeGroup>
```bash title="cURL"
curl --request POST \
     --url https://api.gravv.xyz/v1/risk/upload-document \
     --header 'Api-Key: <API_KEY>' \
     --header 'Idempotency-Key: 6ac14ae7-0f2b-401d-8d89-818a2ca472df' \
     --header 'Content-Type: application/json' \
     --data '
{
  "customer_id": "7d3c9e1a-5b2f-4a8d-9e6c-2f1a0b9c8d7e",
  "content": "<base64 of chidi-id-front.jpg>",
  "filename": "chidi-id-front.jpg",
  "metadata": {
    "idDocType": "id_card",
    "idDocSubType": "FRONT_SIDE",
    "country": "NGA",
    "number": "A12345678",
    "dob": "1994-08-21",
    "validUntil": "2031-05-31"
  }
}
'
```
```javascript title="Node"
const response = await fetch("https://api.gravv.xyz/v1/risk/upload-document", {
  method: "POST",
  headers: {
    "Api-Key": process.env.GRAVV_API_KEY,
    "Idempotency-Key": "6ac14ae7-0f2b-401d-8d89-818a2ca472df",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "customer_id": "7d3c9e1a-5b2f-4a8d-9e6c-2f1a0b9c8d7e",
    "content": "<base64 of chidi-id-front.jpg>",
    "filename": "chidi-id-front.jpg",
    "metadata": {
      "idDocType": "id_card",
      "idDocSubType": "FRONT_SIDE",
      "country": "NGA",
      "number": "A12345678",
      "dob": "1994-08-21",
      "validUntil": "2031-05-31"
    }
  }),
});
console.log(response.status, await response.json());
```
```python title="Python"
import os

import requests

response = requests.post(
    "https://api.gravv.xyz/v1/risk/upload-document",
    headers={
        "Api-Key": os.environ["GRAVV_API_KEY"],
        "Idempotency-Key": "6ac14ae7-0f2b-401d-8d89-818a2ca472df",
        "Content-Type": "application/json",
    },
    json={
        "customer_id": "7d3c9e1a-5b2f-4a8d-9e6c-2f1a0b9c8d7e",
        "content": "<base64 of chidi-id-front.jpg>",
        "filename": "chidi-id-front.jpg",
        "metadata": {
            "idDocType": "id_card",
            "idDocSubType": "FRONT_SIDE",
            "country": "NGA",
            "number": "A12345678",
            "dob": "1994-08-21",
            "validUntil": "2031-05-31",
        },
    },
)
print(response.status_code, response.json())
```
</CodeGroup>

```json title="Response"
{
  "data": {
    "status": "success",
    "customer_id": "7d3c9e1a-5b2f-4a8d-9e6c-2f1a0b9c8d7e",
    "document_type": "ID_CARD",
    "document_sub_type": "FRONT_SIDE",
    "country": "NGA",
    "message": "Document saved. Use /initiate-kyc to submit for verification."
  },
  "error": null
}
```

**Check that `data.status` is `success`.** A bad file returns HTTP 200 with `data.status` `error`. Repeat the upload for the back (`"idDocSubType": "BACK_SIDE"`, `chidi-id-back.jpg`) and the selfie:

<CodeGroup>
```bash title="cURL"
curl --request POST \
     --url https://api.gravv.xyz/v1/risk/upload-document \
     --header 'Api-Key: <API_KEY>' \
     --header 'Idempotency-Key: a7cca1da-b8fd-4075-ab22-890a714e640d' \
     --header 'Content-Type: application/json' \
     --data '
{
  "customer_id": "7d3c9e1a-5b2f-4a8d-9e6c-2f1a0b9c8d7e",
  "content": "<base64 of chidi-selfie.jpg>",
  "filename": "chidi-selfie.jpg",
  "metadata": {
    "idDocType": "selfie",
    "country": "NGA"
  }
}
'
```
```javascript title="Node"
const response = await fetch("https://api.gravv.xyz/v1/risk/upload-document", {
  method: "POST",
  headers: {
    "Api-Key": process.env.GRAVV_API_KEY,
    "Idempotency-Key": "a7cca1da-b8fd-4075-ab22-890a714e640d",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "customer_id": "7d3c9e1a-5b2f-4a8d-9e6c-2f1a0b9c8d7e",
    "content": "<base64 of chidi-selfie.jpg>",
    "filename": "chidi-selfie.jpg",
    "metadata": {
      "idDocType": "selfie",
      "country": "NGA"
    }
  }),
});
console.log(response.status, await response.json());
```
```python title="Python"
import os

import requests

response = requests.post(
    "https://api.gravv.xyz/v1/risk/upload-document",
    headers={
        "Api-Key": os.environ["GRAVV_API_KEY"],
        "Idempotency-Key": "a7cca1da-b8fd-4075-ab22-890a714e640d",
        "Content-Type": "application/json",
    },
    json={
        "customer_id": "7d3c9e1a-5b2f-4a8d-9e6c-2f1a0b9c8d7e",
        "content": "<base64 of chidi-selfie.jpg>",
        "filename": "chidi-selfie.jpg",
        "metadata": {
            "idDocType": "selfie",
            "country": "NGA",
        },
    },
)
print(response.status_code, response.json())
```
</CodeGroup>

### 2. Submit for review

<CodeGroup>
```bash title="cURL"
curl --request POST \
     --url https://api.gravv.xyz/v1/risk/start-kyc-s2s \
     --header 'Api-Key: <API_KEY>' \
     --header 'Idempotency-Key: e8f31fbe-205d-4533-a343-0723a1ba4ab5' \
     --header 'Content-Type: application/json' \
     --data '
{
  "customer_id": "7d3c9e1a-5b2f-4a8d-9e6c-2f1a0b9c8d7e"
}
'
```
```javascript title="Node"
const response = await fetch("https://api.gravv.xyz/v1/risk/start-kyc-s2s", {
  method: "POST",
  headers: {
    "Api-Key": process.env.GRAVV_API_KEY,
    "Idempotency-Key": "e8f31fbe-205d-4533-a343-0723a1ba4ab5",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "customer_id": "7d3c9e1a-5b2f-4a8d-9e6c-2f1a0b9c8d7e"
  }),
});
console.log(response.status, await response.json());
```
```python title="Python"
import os

import requests

response = requests.post(
    "https://api.gravv.xyz/v1/risk/start-kyc-s2s",
    headers={
        "Api-Key": os.environ["GRAVV_API_KEY"],
        "Idempotency-Key": "e8f31fbe-205d-4533-a343-0723a1ba4ab5",
        "Content-Type": "application/json",
    },
    json={
        "customer_id": "7d3c9e1a-5b2f-4a8d-9e6c-2f1a0b9c8d7e",
    },
)
print(response.status_code, response.json())
```
</CodeGroup>

```json title="Response"
{
  "data": {
    "status": "pending",
    "message": "KYC verification initiated. Status will be updated via webhook."
  },
  "error": null
}
```

Every response below is HTTP 200. Branch on `data.status` and `data.code`.

| Response | What to do |
| :-- | :-- |
| `status` `pending` | Wait for `customer.kyc.status.completed` |
| `status` `partial_success` | `failed_documents` says which files Gravv refused and why. Chidi can finish them on a hosted page at `web_url`, which is valid for 1 hour |
| `code` `MISSING_DOCUMENTS` | Upload what `details.missing_documents` lists, then submit again with a new key |
| `code` `KYC_ALREADY_COMPLETED` | Go to step 3 |

### 3. Activate cards

<CodeGroup>
```bash title="cURL"
curl --request POST \
     --url https://api.gravv.xyz/v1/risk/features/activate \
     --header 'Api-Key: <API_KEY>' \
     --header 'Idempotency-Key: d0726dd5-0a9f-4f81-b689-ea11d9051f05' \
     --header 'Content-Type: application/json' \
     --data '
{
  "customer_id": "7d3c9e1a-5b2f-4a8d-9e6c-2f1a0b9c8d7e",
  "feature_id": "virtual_cards",
  "provider_data": {
    "annual_remuneration": 30000,
    "estimated_monthly_limit": 200,
    "account_id": "9b40be8d-9d74-4d2a-8897-dea3ef80ad23"
  }
}
'
```
```javascript title="Node"
const response = await fetch("https://api.gravv.xyz/v1/risk/features/activate", {
  method: "POST",
  headers: {
    "Api-Key": process.env.GRAVV_API_KEY,
    "Idempotency-Key": "d0726dd5-0a9f-4f81-b689-ea11d9051f05",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "customer_id": "7d3c9e1a-5b2f-4a8d-9e6c-2f1a0b9c8d7e",
    "feature_id": "virtual_cards",
    "provider_data": {
      "annual_remuneration": 30000,
      "estimated_monthly_limit": 200,
      "account_id": "9b40be8d-9d74-4d2a-8897-dea3ef80ad23"
    }
  }),
});
console.log(response.status, await response.json());
```
```python title="Python"
import os

import requests

response = requests.post(
    "https://api.gravv.xyz/v1/risk/features/activate",
    headers={
        "Api-Key": os.environ["GRAVV_API_KEY"],
        "Idempotency-Key": "d0726dd5-0a9f-4f81-b689-ea11d9051f05",
        "Content-Type": "application/json",
    },
    json={
        "customer_id": "7d3c9e1a-5b2f-4a8d-9e6c-2f1a0b9c8d7e",
        "feature_id": "virtual_cards",
        "provider_data": {
            "annual_remuneration": 30000,
            "estimated_monthly_limit": 200,
            "account_id": "9b40be8d-9d74-4d2a-8897-dea3ef80ad23",
        },
    },
)
print(response.status_code, response.json())
```
</CodeGroup>

Uploaded documents include no liveness capture, so the response is:

```json title="Response"
{
  "data": {
    "customer_id": "7d3c9e1a-5b2f-4a8d-9e6c-2f1a0b9c8d7e",
    "feature_id": "virtual_cards",
    "status": "pending_liveness",
    "activated_at": "",
    "message": "Liveness verification required. Please complete the verification using the provided WebSDK URL.",
    "websdk_url": "https://<verification-host>/websdk/p/xYz789"
  },
  "error": null
}
```

Show `websdk_url` to Chidi. It is valid for 30 minutes. Gravv has not stored anything yet.

### 4. Finish the liveness check and activate again

Wait for the liveness review, then send step 3 again with a **new** `Idempotency-Key`. The old key replays `pending_liveness` for 24 hours.

<CodeGroup>
```bash title="cURL"
curl --request POST \
     --url https://api.gravv.xyz/v1/risk/features/activate \
     --header 'Api-Key: <API_KEY>' \
     --header 'Idempotency-Key: 65f55391-cb10-44e7-ac60-6b6aa6b0478f' \
     --header 'Content-Type: application/json' \
     --data '
{
  "customer_id": "7d3c9e1a-5b2f-4a8d-9e6c-2f1a0b9c8d7e",
  "feature_id": "virtual_cards",
  "provider_data": {
    "annual_remuneration": 30000,
    "estimated_monthly_limit": 200,
    "account_id": "9b40be8d-9d74-4d2a-8897-dea3ef80ad23"
  }
}
'
```
```javascript title="Node"
const response = await fetch("https://api.gravv.xyz/v1/risk/features/activate", {
  method: "POST",
  headers: {
    "Api-Key": process.env.GRAVV_API_KEY,
    "Idempotency-Key": "65f55391-cb10-44e7-ac60-6b6aa6b0478f",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "customer_id": "7d3c9e1a-5b2f-4a8d-9e6c-2f1a0b9c8d7e",
    "feature_id": "virtual_cards",
    "provider_data": {
      "annual_remuneration": 30000,
      "estimated_monthly_limit": 200,
      "account_id": "9b40be8d-9d74-4d2a-8897-dea3ef80ad23"
    }
  }),
});
console.log(response.status, await response.json());
```
```python title="Python"
import os

import requests

response = requests.post(
    "https://api.gravv.xyz/v1/risk/features/activate",
    headers={
        "Api-Key": os.environ["GRAVV_API_KEY"],
        "Idempotency-Key": "65f55391-cb10-44e7-ac60-6b6aa6b0478f",
        "Content-Type": "application/json",
    },
    json={
        "customer_id": "7d3c9e1a-5b2f-4a8d-9e6c-2f1a0b9c8d7e",
        "feature_id": "virtual_cards",
        "provider_data": {
            "annual_remuneration": 30000,
            "estimated_monthly_limit": 200,
            "account_id": "9b40be8d-9d74-4d2a-8897-dea3ef80ad23",
        },
    },
)
print(response.status_code, response.json())
```
</CodeGroup>

`status` `active` means the application is open. Continue at [Find the application or the card](/recipes/cards/issue-a-virtual-card-after-a-hosted-check#3-find-the-application-or-the-card).

## What can go wrong

| Response | Why | Fix |
| :-- | :-- | :-- |
| 200 with `data.status` `error`, `error_code` `INVALID_REQUEST` | A 2-letter country, a wrong file extension, content that is not JPEG, PNG or PDF, a PDF selfie, an unknown `idDocType`, a date not `YYYY-MM-DD`, or a missing field | Check each rule, then resend with a **new** key |
| 200 with `data.status` `error`, `error_code` `DOCUMENT_ERROR` | Gravv could not store the file | Retry with a new key |
| 400 `grpc: received message larger than max (…)` or 413 `request_body_too_large` | The file is too large | Compress the image under 5 MB |
| 413 `the uploaded file is too large — the maximum is 10MB` | In sandbox, any request of about 600 KB or more, whatever the message says | Compress the image under 500 KB |
| 400 `INTERNAL_ERROR: Failed to get KYC information` | The customer has no verification record | Check that the customer id is yours |
| 200 with `code` `MISSING_DOCUMENTS` | You did not upload a required file | Upload it, then submit again with a new key |
| 200 with `status` `partial_success` | Gravv refused one or more files | Send Chidi to `web_url`, or upload again and resubmit |
| `pending_liveness` again after Chidi finished | You reused the first key, or the review is not back yet | Wait for the KYC webhook, then activate again with a new key |
| 400 `failed to check liveness requirement: customer applicant_id is required for liveness verification` | The person has no verification record | Verify them with [the hosted check](/recipes/cards/issue-a-virtual-card-after-a-hosted-check) |

## Webhooks you will receive

You receive `customer.kyc.status.completed`, or `customer.kyc.status.failed` with `failure_reason`. This route sends no `customer.kyc.status.pending` event. After activation, you receive the card events listed in [the hosted-check recipe](/recipes/cards/issue-a-virtual-card-after-a-hosted-check#webhooks-you-will-receive).

## Testing in sandbox

Upload small valid images, under 500 KB each. A 1×1 PNG passes the content check. **Sandbox KYC does not complete today**, so a new customer stays `pending` and activation returns `customer KYC must be completed`. Activation for a verified customer with no liveness returns `pending_liveness`, as in live.

## Reference

- [Upload document](/api-reference/card-kyc/post-v1-risk-upload-document)
- [Start KYC verification (server to server)](/api-reference/card-kyc/post-v1-risk-start-kyc-s2s)
- [Activate feature](/platform/cards/card-kyc/activate-feature)
