Verify a person on a hosted page

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

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

curl --request GET \
 --url 'https://api.gravv.xyz/v1/customers?keyword=amara.nwosu@example.com' \
 --header 'Api-Key: <API_KEY>'
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());
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())

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

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"
}
}
'
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());
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())
{
  "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

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"
}
'
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());
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())
{
  "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:

curl --request GET \
 --url https://api.gravv.xyz/v1/customers/3f6b2a1e-8c4d-4e7f-9a2b-5c1d0e9f8a71 \
 --header 'Api-Key: <API_KEY>'
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());
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())
{
  "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.
  • 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 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
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.

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

Was this page helpful?