Verify a cardholder with your own ID capture
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
- You upload Chidi's ID front, ID back and selfie, and submit them. No money moves.
- Verification completes. The activation response asks for a liveness check, and Chidi finishes it on his phone.
- 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
individualcustomer, as listed in Issue a virtual card after a hosted check. - 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,.pngor.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.
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"
}
}
'
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());
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())
{
"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:
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"
}
}
'
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());
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())
2. Submit for review
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"
}
'
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());
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())
{
"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
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"
}
}
'
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());
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())
Uploaded documents include no liveness capture, so the response is:
{
"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.
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"
}
}
'
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());
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())
status active means the application is open. Continue at 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 |
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.
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.