---
title: "Verify a person on a hosted page"
description: "Create an individual customer, send them to Gravv's hosted ID and liveness check, and wait for basic KYC to complete."
---
This recipe creates an individual customer, sends the customer to Gravv's hosted ID and liveness check, and waits for `basic` KYC to complete.

**Who it is for.** A Lagos savings app that signs up Amara, a salaried nurse, and wants Gravv's hosted page to check her ID and take a liveness selfie.

**Status.** Available. In sandbox, the review does not complete today (see [Testing in sandbox](#testing-in-sandbox)).

## Money example

No money moves in this recipe, but it prepares Amara to move money:

1. You create Amara. Her KYC is `not_started`, so on the standard limits she can move 500.00 USD a day.
2. She passes the hosted check. Her KYC is `completed`, and her limit becomes 50,000.00 USD a day.
3. You can now open her a USD account and receive her 1,200.00 USD salary into it.

## Before you start

- You have an API key. A scoped key needs `customer:create`, and `customer:update` or `customer:kyc_override`.
- You have registered a webhook URL in the Dashboard, under Manage, then Configure, then Webhooks, to receive `customer.kyc.status.*`. Without one, read the customer instead.
- You have Amara's legal name as on her ID, her email, her phone with country code, her date of birth, her gender, and her home address with city, postal code, ISO state and country.

## Find out what is required

- No call lists the create fields. The rules are fixed, and [Onboard your first customer](/getting-started/onboard-your-first-customer#create-an-individual-customer) lists them. A wrong field returns a 400 that names the field.
- You send no documents. The hosted page asks the person for one ID (passport, national ID card or driver's license) and a selfie.
- To find out whether she already exists, call `GET /v1/customers?keyword=<email>`. Each email can belong to only one customer in your business.

## Steps

### 1. Check she is not already a customer

<CodeGroup>
```bash title="cURL"
curl --request GET \
     --url 'https://api.gravv.xyz/v1/customers?keyword=amara.nwosu@example.com' \
     --header 'Api-Key: <API_KEY>'
```
```javascript title="Node"
const response = await fetch("https://api.gravv.xyz/v1/customers?keyword=amara.nwosu@example.com", {
  method: "GET",
  headers: {
    "Api-Key": process.env.GRAVV_API_KEY,
  },
});
console.log(response.status, await response.json());
```
```python title="Python"
import os

import requests

response = requests.get(
    "https://api.gravv.xyz/v1/customers?keyword=amara.nwosu@example.com",
    headers={
        "Api-Key": os.environ["GRAVV_API_KEY"],
    },
)
print(response.status_code, response.json())
```
</CodeGroup>

`keyword` matches first name, last name, email or id. If she is listed, reuse her `id` and go to step 3.

### 2. Create the customer

<CodeGroup>
```bash title="cURL"
curl --request POST \
     --url https://api.gravv.xyz/v1/customers \
     --header 'Api-Key: <API_KEY>' \
     --header 'Idempotency-Key: 1de4eddf-c89f-43b2-a3ec-f49938935ca6' \
     --header 'Content-Type: application/json' \
     --data '
{
  "type": "individual",
  "first_name": "Amara",
  "last_name": "Nwosu",
  "email": "amara.nwosu@example.com",
  "phone": "+2348031234567",
  "gender": "female",
  "date_of_birth": "1992-03-14",
  "external_id": "user-88412",
  "address": {
    "address_line1": "14 Bourdillon Road",
    "city": "Lagos",
    "state": "NG-LA",
    "postal_code": "101233",
    "country": "NG"
  }
}
'
```
```javascript title="Node"
const response = await fetch("https://api.gravv.xyz/v1/customers", {
  method: "POST",
  headers: {
    "Api-Key": process.env.GRAVV_API_KEY,
    "Idempotency-Key": "1de4eddf-c89f-43b2-a3ec-f49938935ca6",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "type": "individual",
    "first_name": "Amara",
    "last_name": "Nwosu",
    "email": "amara.nwosu@example.com",
    "phone": "+2348031234567",
    "gender": "female",
    "date_of_birth": "1992-03-14",
    "external_id": "user-88412",
    "address": {
      "address_line1": "14 Bourdillon Road",
      "city": "Lagos",
      "state": "NG-LA",
      "postal_code": "101233",
      "country": "NG"
    }
  }),
});
console.log(response.status, await response.json());
```
```python title="Python"
import os

import requests

response = requests.post(
    "https://api.gravv.xyz/v1/customers",
    headers={
        "Api-Key": os.environ["GRAVV_API_KEY"],
        "Idempotency-Key": "1de4eddf-c89f-43b2-a3ec-f49938935ca6",
        "Content-Type": "application/json",
    },
    json={
        "type": "individual",
        "first_name": "Amara",
        "last_name": "Nwosu",
        "email": "amara.nwosu@example.com",
        "phone": "+2348031234567",
        "gender": "female",
        "date_of_birth": "1992-03-14",
        "external_id": "user-88412",
        "address": {
            "address_line1": "14 Bourdillon Road",
            "city": "Lagos",
            "state": "NG-LA",
            "postal_code": "101233",
            "country": "NG",
        },
    },
)
print(response.status_code, response.json())
```
</CodeGroup>

```json title="Response"
{
  "data": {
    "id": "3f6b2a1e-8c4d-4e7f-9a2b-5c1d0e9f8a71",
    "type": "individual",
    "status": "active",
    "first_name": "Amara",
    "last_name": "Nwosu",
    "email": "amara.nwosu@example.com",
    "phone": "+2348031234567",
    "gender": "female",
    "date_of_birth": "1992-03-14",
    "external_id": "user-88412",
    "date_added": "2026-10-13T23:30:02.677140979+01:00",
    "address": {
      "address_line1": "14 Bourdillon Road",
      "city": "Lagos",
      "state": "NG-LA",
      "postal_code": "101233",
      "country": "NG"
    }
  },
  "error": null
}
```

Save `data.id`. The response has no `kyc` list. Gravv creates the `basic` and `intermediate` rows at the same time, both `not_started`.

### 3. Start the hosted check

<CodeGroup>
```bash title="cURL"
curl --request POST \
     --url https://api.gravv.xyz/v1/customers/kyc/start \
     --header 'Api-Key: <API_KEY>' \
     --header 'Idempotency-Key: 99266915-b2ed-4563-9ede-0c7a9d9f965f' \
     --header 'Content-Type: application/json' \
     --data '
{
  "customer_id": "3f6b2a1e-8c4d-4e7f-9a2b-5c1d0e9f8a71"
}
'
```
```javascript title="Node"
const response = await fetch("https://api.gravv.xyz/v1/customers/kyc/start", {
  method: "POST",
  headers: {
    "Api-Key": process.env.GRAVV_API_KEY,
    "Idempotency-Key": "99266915-b2ed-4563-9ede-0c7a9d9f965f",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "customer_id": "3f6b2a1e-8c4d-4e7f-9a2b-5c1d0e9f8a71"
  }),
});
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",
    headers={
        "Api-Key": os.environ["GRAVV_API_KEY"],
        "Idempotency-Key": "99266915-b2ed-4563-9ede-0c7a9d9f965f",
        "Content-Type": "application/json",
    },
    json={
        "customer_id": "3f6b2a1e-8c4d-4e7f-9a2b-5c1d0e9f8a71",
    },
)
print(response.status_code, response.json())
```
</CodeGroup>

```json title="Response"
{
  "data": {
    "review_status": "pending",
    "status": "success",
    "web_url": "https://<verification-host>/websdk/p/Kq3x9TbV2m"
  },
  "error": null
}
```

Open `web_url` for Amara, as a redirect or in an in-app browser. The link is valid for **1 hour**. Her `basic` row is now `pending` and carries the same link as `kyc_url`. Gravv bills your business once for the check. If the link expires, call start again with a **new** key. You get a new link, with no second fee.

### 4. Wait for the result

Wait for `customer.kyc.status.completed` or `customer.kyc.status.failed`, or read the customer:

<CodeGroup>
```bash title="cURL"
curl --request GET \
     --url https://api.gravv.xyz/v1/customers/3f6b2a1e-8c4d-4e7f-9a2b-5c1d0e9f8a71 \
     --header 'Api-Key: <API_KEY>'
```
```javascript title="Node"
const response = await fetch("https://api.gravv.xyz/v1/customers/3f6b2a1e-8c4d-4e7f-9a2b-5c1d0e9f8a71", {
  method: "GET",
  headers: {
    "Api-Key": process.env.GRAVV_API_KEY,
  },
});
console.log(response.status, await response.json());
```
```python title="Python"
import os

import requests

response = requests.get(
    "https://api.gravv.xyz/v1/customers/3f6b2a1e-8c4d-4e7f-9a2b-5c1d0e9f8a71",
    headers={
        "Api-Key": os.environ["GRAVV_API_KEY"],
    },
)
print(response.status_code, response.json())
```
</CodeGroup>

```json title="Response, trimmed to the KYC rows"
{
  "data": {
    "id": "3f6b2a1e-8c4d-4e7f-9a2b-5c1d0e9f8a71",
    "kyc": [
      {
        "id": "8b2d4f6a-3c5e-4a7b-9d0f-1e2a3b4c5d6e",
        "type": "intermediate",
        "status": "not_started",
        "verified_at": "2026-10-13T23:30:02.896119+01:00"
      },
      {
        "id": "7a1c3e5f-2b4d-4f6a-8c9e-0d1f2a3b4c5d",
        "type": "basic",
        "status": "pending",
        "kyc_url": "https://<verification-host>/websdk/p/Kq3x9TbV2m",
        "verified_at": "2026-10-13T23:30:02.844285+01:00"
      }
    ]
  },
  "error": null
}
```

- Find the row by `type`, not by position, because the order is not fixed.
- The `basic` row moves from `not_started` to `pending`, then to `completed` or `failed`. A `failed` row carries a `remark`. See [Resubmit KYC after a rejection](/recipes/customers-and-kyc/resubmit-kyc-after-a-rejection).
- Gravv sets `verified_at` on every row, `not_started` included. It holds the row's creation time and does not mean "verified". Use `status`.
- [`GET /v1/customers/{customer_id}/kyc/status`](/api-reference/kyc/get-v1-customers-customer-id-kyc-status) shows the live review, for display. The decision that counts is the row and the webhook. On the live review, `reviewStatus` `completed` with `reviewAnswer` `rejected` means failed.

## What can go wrong

| Response | Why | Fix |
| :-- | :-- | :-- |
| 400 `failed to add customer: email already exists` | The email belongs to another customer in your business | Find it with `?keyword=<email>` and reuse it |
| 400 `failed to add customer: external_id already exists` | Another customer already has that `external_id` | Send another one, or leave it out |
| 400 `phone number is not valid` | The number is not a real, dialable number | Send E.164 with the country code |
| 400 `invalid state "LA": expected ISO 3166-2 code (e.g. "NG-LA")` | You sent a bare state code | Send `NG-LA` |
| 400 `first name cannot contain special characters` | The name has an accent, digit or dot | Send `Adaeze`, not `Ádaeze` |
| 400 with the same text after you fixed the body | Gravv replayed the old response under the same key | Use a new key |
| 400 `INVALID_REQUEST: this tenant uses document-based auto verification; upload required documents instead` | Your business verifies from documents alone | [Upload the documents](/recipes/customers-and-kyc/verify-a-person-with-documents-you-upload) |
| 200 with `review_status` `completed` and no `web_url` | She is already verified | No action is needed |
| The old, expired link comes back | You reused the first key | Use a new key |
| 404 `CUSTOMER_NOT_FOUND: Customer not found. …` | The id is wrong, or comes from the other environment | Check the id and the key's environment |
| 503 `verification provider not configured` | A temporary fault | Retry later with the same key |

## Webhooks you will receive

1. `customer.created`. Its `event_data` holds `id`, `type`, `status`, `email`, `address`, `created_at` and the two `not_started` KYC rows. It carries no names and no phone.
2. `customer.kyc.status.pending`, after the start.
3. `customer.kyc.status.completed` (with `completed_at`) or `customer.kyc.status.failed` (with `failure_reason`).

Deduplicate webhooks on the body's `event_id`, never on `X-Event-ID`. See [Customer and KYC events](/platform/webhooks/event-catalogue#customers-and-kyc).

## Testing in sandbox

Creating the customer, starting KYC and opening the hosted link work with a `grvSec_sandbox_` key. The link opens the verification service's own test page. **The review does not complete today.** The `basic` row stays `pending`, and Gravv sends no `completed` or `failed` webhook. Until Gravv fixes this, a sandbox customer cannot reach `completed`, so you cannot finish steps that need it (accounts, cards) in sandbox.

## Reference

- [Create a customer](/api-reference/customers/post-v1-customers)
- [Get a customer](/api-reference/customers/get-v1-customers-customer-id)
- [Start KYC verification](/api-reference/kyc/post-v1-customers-kyc-start)
- [Get applicant verification status](/api-reference/kyc/get-v1-customers-customer-id-kyc-status)
- [KYC](/getting-started/kyc)
