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:
- You create Amara. Her KYC is
not_started, so on the standard limits she can move 500.00 USD a day. - She passes the hosted check. Her KYC is
completed, and her limit becomes 50,000.00 USD a day. - 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, andcustomer:updateorcustomer: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
basicrow moves fromnot_startedtopending, then tocompletedorfailed. Afailedrow carries aremark. See Resubmit KYC after a rejection. - Gravv sets
verified_aton every row,not_startedincluded. It holds the row's creation time and does not mean "verified". Usestatus. GET /v1/customers/{customer_id}/kyc/statusshows the live review, for display. The decision that counts is the row and the webhook. On the live review,reviewStatuscompletedwithreviewAnswerrejectedmeans 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
customer.created. Itsevent_dataholdsid,type,status,email,address,created_atand the twonot_startedKYC rows. It carries no names and no phone.customer.kyc.status.pending, after the start.customer.kyc.status.completed(withcompleted_at) orcustomer.kyc.status.failed(withfailure_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.