Verify a person with documents you upload
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
- Kofi has no completed KYC, so on the standard limits he can move 500.00 USD a day.
- You upload his card and selfie and start verification. Gravv verifies him, and his limit becomes 50,000.00 USD a day.
- His 800.00 USD monthly rent payout now goes through without a limits pause.
Before you start
- Kofi exists as a customer (create him), here with address country
GH. Hisbasicrow is notcompleted. - 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 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,PASSPORTorDRIVERS) with aFRONT_SIDEimage, aBACK_SIDEupload for a two-sided card, and oneSELFIE. Gravv acceptsPROOF_OF_ADDRESSandTAX_DECLARATIONbut does not require them. GET /v1/customers/{customer_id}/kyc/documentsshows 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.
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"
}
}
'
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());
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())
{
"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:
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"
}
}
'
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());
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())
The back gets its own document_id.
3. Upload the selfie
A selfie needs no country and no sub-type.
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"
}
}
'
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());
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())
Uploading the same type and side again replaces the earlier file.
4. Start verification
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"
}
'
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());
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())
{
"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}, as in the hosted recipe.
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 |
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.
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.