---
title: "Verify a person with documents you upload"
description: "Upload an ID and a selfie you already hold, start server-to-server verification, and wait for basic KYC to complete."
---
This recipe uploads an ID and a selfie you already hold, starts server-to-server verification and waits for `basic` KYC to complete.

**Who it is for.** A remittance app whose own sign-up screen already captures Kofi's Ghana card, front and back, and a selfie. The app wants no hosted page.

**Status.** Available. This flow records no liveness check, which matters only if he later needs a card. In sandbox the review does not complete today.

## Money example

1. Kofi has no completed KYC, so on the standard limits he can move 500.00 USD a day.
2. You upload his card and selfie and start verification. Gravv verifies him, and his limit becomes 50,000.00 USD a day.
3. His 800.00 USD monthly rent payout now goes through without a limits pause.

## Before you start

- Kofi exists as a customer ([create him](/recipes/customers-and-kyc/verify-a-person-on-a-hosted-page#2-create-the-customer)), here with address country `GH`. His `basic` row is not `completed`.
- A scoped key needs `customer:update`.
- Files are JPEG, PNG or PDF. The selfie is JPEG or PNG. Each file can be at most **10 MB**. In sandbox, keep each file **under 500 KB**, because sandbox refuses requests of about 600 KB or more.
- Gravv does not deduplicate a request body over 1 MiB under its `Idempotency-Key`. A file over about 770 KB makes a body that large, so a retried upload of it runs again instead of returning the first response. After a timeout, read the [documents list](/api-reference/kyc/get-v1-customers-customer-id-kyc-documents) before you upload again.
- Gravv bills every server-to-server start. Call it once, when the set is complete.

## Find out what is required

- A person needs **one** identity document (`ID_CARD`, `PASSPORT` or `DRIVERS`) with a `FRONT_SIDE` image, a `BACK_SIDE` upload for a two-sided card, **and** one `SELFIE`. Gravv accepts `PROOF_OF_ADDRESS` and `TAX_DECLARATION` but does not require them.
- [`GET /v1/customers/{customer_id}/kyc/documents`](/api-reference/kyc/get-v1-customers-customer-id-kyc-documents) shows what is already stored.
- If anything is missing, the start fails and lists it, so you can use the start as the check.

## Steps

### 1. Upload the ID front

`content` is the **whole file as plain base64**, with no `data:image/jpeg;base64,` prefix. The samples here shorten it.

<CodeGroup>
```bash title="cURL"
curl --request POST \
     --url https://api.gravv.xyz/v1/customers/kyc/upload-document \
     --header 'Api-Key: <API_KEY>' \
     --header 'Idempotency-Key: bf15ae40-1359-4ae6-9fbd-7c35b3b27ac1' \
     --header 'Content-Type: application/json' \
     --data '
{
  "customer_id": "9d2f4b6a-1c3e-4a5b-8d7f-2e4a6c8e0b13",
  "filename": "ghana-card-front.jpg",
  "content": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8U",
  "metadata": {
    "idDocType": "ID_CARD",
    "idDocSubType": "FRONT_SIDE",
    "country": "GH",
    "number": "GHA-723456789-1",
    "issuedDate": "2021-05-04",
    "validUntil": "2031-05-03",
    "firstName": "Kofi",
    "lastName": "Mensah"
  }
}
'
```
```javascript title="Node"
const response = await fetch("https://api.gravv.xyz/v1/customers/kyc/upload-document", {
  method: "POST",
  headers: {
    "Api-Key": process.env.GRAVV_API_KEY,
    "Idempotency-Key": "bf15ae40-1359-4ae6-9fbd-7c35b3b27ac1",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "customer_id": "9d2f4b6a-1c3e-4a5b-8d7f-2e4a6c8e0b13",
    "filename": "ghana-card-front.jpg",
    "content": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8U",
    "metadata": {
      "idDocType": "ID_CARD",
      "idDocSubType": "FRONT_SIDE",
      "country": "GH",
      "number": "GHA-723456789-1",
      "issuedDate": "2021-05-04",
      "validUntil": "2031-05-03",
      "firstName": "Kofi",
      "lastName": "Mensah"
    }
  }),
});
console.log(response.status, await response.json());
```
```python title="Python"
import os

import requests

response = requests.post(
    "https://api.gravv.xyz/v1/customers/kyc/upload-document",
    headers={
        "Api-Key": os.environ["GRAVV_API_KEY"],
        "Idempotency-Key": "bf15ae40-1359-4ae6-9fbd-7c35b3b27ac1",
        "Content-Type": "application/json",
    },
    json={
        "customer_id": "9d2f4b6a-1c3e-4a5b-8d7f-2e4a6c8e0b13",
        "filename": "ghana-card-front.jpg",
        "content": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8U",
        "metadata": {
            "idDocType": "ID_CARD",
            "idDocSubType": "FRONT_SIDE",
            "country": "GH",
            "number": "GHA-723456789-1",
            "issuedDate": "2021-05-04",
            "validUntil": "2031-05-03",
            "firstName": "Kofi",
            "lastName": "Mensah",
        },
    },
)
print(response.status_code, response.json())
```
</CodeGroup>

```json title="Response"
{
  "data": {
    "status": "success",
    "customer_id": "9d2f4b6a-1c3e-4a5b-8d7f-2e4a6c8e0b13",
    "document_type": "ID_CARD",
    "document_sub_type": "FRONT_SIDE",
    "document_id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
    "country": "GHA",
    "message": "Document saved. Use /start-kyc to submit for verification."
  },
  "error": null
}
```

`country` comes back as three letters. The message names `/start-kyc`, but the next call for documents you upload is `start-s2s` (step 4).

### 2. Upload the ID back

Send the same call and `metadata`, with `"idDocSubType": "BACK_SIDE"` and the back image:

<CodeGroup>
```bash title="cURL"
curl --request POST \
     --url https://api.gravv.xyz/v1/customers/kyc/upload-document \
     --header 'Api-Key: <API_KEY>' \
     --header 'Idempotency-Key: 8170fbe2-8c81-4335-a34b-73b537831022' \
     --header 'Content-Type: application/json' \
     --data '
{
  "customer_id": "9d2f4b6a-1c3e-4a5b-8d7f-2e4a6c8e0b13",
  "filename": "ghana-card-back.jpg",
  "content": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8V",
  "metadata": {
    "idDocType": "ID_CARD",
    "idDocSubType": "BACK_SIDE",
    "country": "GH",
    "number": "GHA-723456789-1",
    "issuedDate": "2021-05-04",
    "validUntil": "2031-05-03"
  }
}
'
```
```javascript title="Node"
const response = await fetch("https://api.gravv.xyz/v1/customers/kyc/upload-document", {
  method: "POST",
  headers: {
    "Api-Key": process.env.GRAVV_API_KEY,
    "Idempotency-Key": "8170fbe2-8c81-4335-a34b-73b537831022",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "customer_id": "9d2f4b6a-1c3e-4a5b-8d7f-2e4a6c8e0b13",
    "filename": "ghana-card-back.jpg",
    "content": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8V",
    "metadata": {
      "idDocType": "ID_CARD",
      "idDocSubType": "BACK_SIDE",
      "country": "GH",
      "number": "GHA-723456789-1",
      "issuedDate": "2021-05-04",
      "validUntil": "2031-05-03"
    }
  }),
});
console.log(response.status, await response.json());
```
```python title="Python"
import os

import requests

response = requests.post(
    "https://api.gravv.xyz/v1/customers/kyc/upload-document",
    headers={
        "Api-Key": os.environ["GRAVV_API_KEY"],
        "Idempotency-Key": "8170fbe2-8c81-4335-a34b-73b537831022",
        "Content-Type": "application/json",
    },
    json={
        "customer_id": "9d2f4b6a-1c3e-4a5b-8d7f-2e4a6c8e0b13",
        "filename": "ghana-card-back.jpg",
        "content": "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8V",
        "metadata": {
            "idDocType": "ID_CARD",
            "idDocSubType": "BACK_SIDE",
            "country": "GH",
            "number": "GHA-723456789-1",
            "issuedDate": "2021-05-04",
            "validUntil": "2031-05-03",
        },
    },
)
print(response.status_code, response.json())
```
</CodeGroup>

The back gets its own `document_id`.

### 3. Upload the selfie

A selfie needs no `country` and no sub-type.

<CodeGroup>
```bash title="cURL"
curl --request POST \
     --url https://api.gravv.xyz/v1/customers/kyc/upload-document \
     --header 'Api-Key: <API_KEY>' \
     --header 'Idempotency-Key: 75c85b3a-4ec1-4d06-b58f-3377a9534aa9' \
     --header 'Content-Type: application/json' \
     --data '
{
  "customer_id": "9d2f4b6a-1c3e-4a5b-8d7f-2e4a6c8e0b13",
  "filename": "selfie.jpg",
  "content": "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQ",
  "metadata": {
    "idDocType": "SELFIE"
  }
}
'
```
```javascript title="Node"
const response = await fetch("https://api.gravv.xyz/v1/customers/kyc/upload-document", {
  method: "POST",
  headers: {
    "Api-Key": process.env.GRAVV_API_KEY,
    "Idempotency-Key": "75c85b3a-4ec1-4d06-b58f-3377a9534aa9",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "customer_id": "9d2f4b6a-1c3e-4a5b-8d7f-2e4a6c8e0b13",
    "filename": "selfie.jpg",
    "content": "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQ",
    "metadata": {
      "idDocType": "SELFIE"
    }
  }),
});
console.log(response.status, await response.json());
```
```python title="Python"
import os

import requests

response = requests.post(
    "https://api.gravv.xyz/v1/customers/kyc/upload-document",
    headers={
        "Api-Key": os.environ["GRAVV_API_KEY"],
        "Idempotency-Key": "75c85b3a-4ec1-4d06-b58f-3377a9534aa9",
        "Content-Type": "application/json",
    },
    json={
        "customer_id": "9d2f4b6a-1c3e-4a5b-8d7f-2e4a6c8e0b13",
        "filename": "selfie.jpg",
        "content": "/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODwwQ",
        "metadata": {
            "idDocType": "SELFIE",
        },
    },
)
print(response.status_code, response.json())
```
</CodeGroup>

Uploading the same type and side again **replaces** the earlier file.

### 4. Start verification

<CodeGroup>
```bash title="cURL"
curl --request POST \
     --url https://api.gravv.xyz/v1/customers/kyc/start-s2s \
     --header 'Api-Key: <API_KEY>' \
     --header 'Idempotency-Key: eab31c93-cb44-43ab-8bbb-766344b4fc05' \
     --header 'Content-Type: application/json' \
     --data '
{
  "customer_id": "9d2f4b6a-1c3e-4a5b-8d7f-2e4a6c8e0b13"
}
'
```
```javascript title="Node"
const response = await fetch("https://api.gravv.xyz/v1/customers/kyc/start-s2s", {
  method: "POST",
  headers: {
    "Api-Key": process.env.GRAVV_API_KEY,
    "Idempotency-Key": "eab31c93-cb44-43ab-8bbb-766344b4fc05",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "customer_id": "9d2f4b6a-1c3e-4a5b-8d7f-2e4a6c8e0b13"
  }),
});
console.log(response.status, await response.json());
```
```python title="Python"
import os

import requests

response = requests.post(
    "https://api.gravv.xyz/v1/customers/kyc/start-s2s",
    headers={
        "Api-Key": os.environ["GRAVV_API_KEY"],
        "Idempotency-Key": "eab31c93-cb44-43ab-8bbb-766344b4fc05",
        "Content-Type": "application/json",
    },
    json={
        "customer_id": "9d2f4b6a-1c3e-4a5b-8d7f-2e4a6c8e0b13",
    },
)
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
}
```

On a business that Gravv set up for automatic verification, the response is `{"status": "completed", "message": "KYC auto-approved for this tenant."}`, and no further step is needed.

### 5. Wait for the result

Wait for `customer.kyc.status.completed` or `customer.kyc.status.failed`, or read `kyc[type=basic].status` on [`GET /v1/customers/{customer_id}`](/api-reference/customers/get-v1-customers-customer-id), as in [the hosted recipe](/recipes/customers-and-kyc/verify-a-person-on-a-hosted-page#4-wait-for-the-result).

The documents list holds the document number in plain text. Do not log it or show it to people who should not see it.

## What can go wrong

| Response | Why | Fix |
| :-- | :-- | :-- |
| 400 `INVALID_REQUEST: missing required documents: Identity Document (ID card, passport, or driver's license), Selfie` | The set is incomplete. The error lists only what is missing | Upload what is named, then start again with a new key |
| 400 `INVALID_REQUEST: no documents found; please upload all required documents before initiating S2S verification` | You uploaded nothing | Upload the documents first |
| 409 `KYC_ALREADY_COMPLETED: KYC verification has already been completed for this customer.` | The customer is already verified | No action is needed |
| 400 `INVALID_REQUEST: document content is not valid base64` | The content has a `data:` prefix, line breaks or URL-safe base64 | Send plain standard base64 of the file |
| 400 `INVALID_REQUEST: unsupported file type "image/gif"; allowed types are JPEG, PNG, and PDF` | Gravv reads the type from the content and ignores the file name | Convert the file |
| 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. In live, a file over about 10 MB | Compress or resize it to under 500 KB in sandbox, or 10 MB or less in live |
| 400 `INVALID_REQUEST: metadata.idDocSubType is required for identity document uploads (e.g. FRONT_SIDE, BACK_SIDE)` | The ID has no side | Add `FRONT_SIDE` or `BACK_SIDE` |
| 400 `INVALID_REQUEST: metadata.number is required for identity document uploads` | The document number (or `validUntil`) is missing | Add it, with dates as `YYYY-MM-DD` |
| 400 `INVALID_REQUEST: metadata.country is required` | The ID has no country | Send a two- or three-letter country code |
| 400 `INVALID_REQUEST: document type "SELFIE_VIDEO" is not allowed for individual customers; …` | The type is unknown | Use one of the listed types |
| 500 `failed to upload documents to verification provider` | Sending the documents failed | Retry the start with the same key |
| A later `customer.kyc.status.failed` | The review rejected a document | [Resubmit](/recipes/customers-and-kyc/resubmit-kyc-after-a-rejection) |

Gravv does not check `validUntil` on upload, so Gravv stores `03/05/2031` as written. Always send `YYYY-MM-DD`, or the review may not read it.

## Webhooks you will receive

You receive `customer.kyc.status.pending` after the start, then `customer.kyc.status.completed` or `customer.kyc.status.failed` with `failure_reason`. Deduplicate webhooks on the body's `event_id`. See [Customer and KYC events](/platform/webhooks/event-catalogue#customers-and-kyc).

## Testing in sandbox

Uploads, the documents list and the start work with a `grvSec_sandbox_` key. Use small real JPEGs under 500 KB. Gravv reads the type from the content, so it refuses a renamed text file. **The review does not complete today.** The row stays `pending`. To test the "completes on upload" path, ask support to set up your sandbox business for automatic verification.

## Reference

- [Upload a KYC document](/api-reference/kyc/post-v1-customers-kyc-upload-document)
- [Initiate server-to-server verification](/api-reference/kyc/post-v1-customers-kyc-start-s2s)
- [Get KYC document data](/api-reference/kyc/get-v1-customers-customer-id-kyc-documents)
- [KYC](/getting-started/kyc)
