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

  1. Kofi has no completed KYC, so on the standard limits he can move 500.00 USD a day.
  2. You upload his card and selfie and start verification. Gravv verifies him, and his limit becomes 50,000.00 USD a day.
  3. 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. His basic row is not completed.
  • 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, PASSPORT or DRIVERS) with a FRONT_SIDE image, a BACK_SIDE upload for a two-sided card, and one SELFIE. Gravv accepts PROOF_OF_ADDRESS and TAX_DECLARATION but does not require them.
  • GET /v1/customers/{customer_id}/kyc/documents shows 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.

Reference

Was this page helpful?