Issue a virtual card after a hosted check

Verify a person on a hosted page with a liveness check, activate cards for them, and issue their first virtual USD card.

Use this recipe to verify a person on a hosted page with a liveness check, activate cards for them, and issue their first virtual USD card.

Who it is for. A savings app in Lagos that gives its users a USD virtual card for online subscriptions.

Status. Available.

Money example

  1. You issue Ada a virtual card with a daily limit of 300 USD. No money moves yet, and the card balance is 0.
  2. You move 150 USD from Ada's USD account to the card, as in Top up a card. The card balance is 150 USD.
  3. Ada pays 18.75 USD for a streaming subscription. The card balance is 131.25 USD.

Before you start

  • Your business's card account is active. To find it, or to set it up if there is none, see Set up your card account. In this recipe, its id is 9b40be8d-9d74-4d2a-8897-dea3ef80ad23.
  • Ada exists as an individual customer with a phone number and an address. See Customers and KYC.
  • If your API key is scoped, it holds feature:read, feature:activate, customer:update and card:create.
  • Card issuing is switched on for your business. If a card call returns 403 with feature_access_required, it is not switched on. Request access.
  • You have registered your webhook URL in the Dashboard.

Find out what is required

Read Ada's basic KYC first. If it is already completed, skip step 1.

curl --request GET \
 --url https://api.gravv.xyz/v1/customers/5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42 \
 --header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/customers/5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42", {
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/5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42",
headers={
    "Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())

Then check what activation still needs. eligible false is normal before activation. The provider_data items are fields you send at activation.

curl --request POST \
 --url https://api.gravv.xyz/v1/risk/features/eligibility \
 --header 'Api-Key: <API_KEY>' \
 --header 'Idempotency-Key: aa00412e-7fc8-4bf7-8dd2-4bc82e8ea4a6' \
 --header 'Content-Type: application/json' \
 --data '
{
"customer_id": "5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42",
"feature_id": "virtual_cards"
}
'
const response = await fetch("https://api.gravv.xyz/v1/risk/features/eligibility", {
method: "POST",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
"Idempotency-Key": "aa00412e-7fc8-4bf7-8dd2-4bc82e8ea4a6",
"Content-Type": "application/json",
},
body: JSON.stringify({
"customer_id": "5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42",
"feature_id": "virtual_cards"
}),
});
console.log(response.status, await response.json());
import os

import requests

response = requests.post(
"https://api.gravv.xyz/v1/risk/features/eligibility",
headers={
    "Api-Key": os.environ["GRAVV_API_KEY"],
    "Idempotency-Key": "aa00412e-7fc8-4bf7-8dd2-4bc82e8ea4a6",
    "Content-Type": "application/json",
},
json={
    "customer_id": "5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42",
    "feature_id": "virtual_cards",
},
)
print(response.status_code, response.json())
Pending item What to do
type kyc_status Verify Ada first (step 1)
type provider_data, required annual_remuneration or estimated_monthly_limit Send it at activation (step 2)
type provider_data, required ip_address Nothing. Gravv fills it from your request

Steps

1. Start hosted verification

Run this step only when Ada's basic KYC is not_started or failed. The hosted page captures her ID, a selfie and a liveness check in one session.

curl --request POST \
 --url https://api.gravv.xyz/v1/risk/start-kyc \
 --header 'Api-Key: <API_KEY>' \
 --header 'Idempotency-Key: b7b49982-1e90-458b-800f-7376e11f79c2' \
 --header 'Content-Type: application/json' \
 --data '
{
"customer_id": "5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42"
}
'
const response = await fetch("https://api.gravv.xyz/v1/risk/start-kyc", {
method: "POST",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
"Idempotency-Key": "b7b49982-1e90-458b-800f-7376e11f79c2",
"Content-Type": "application/json",
},
body: JSON.stringify({
"customer_id": "5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42"
}),
});
console.log(response.status, await response.json());
import os

import requests

response = requests.post(
"https://api.gravv.xyz/v1/risk/start-kyc",
headers={
    "Api-Key": os.environ["GRAVV_API_KEY"],
    "Idempotency-Key": "b7b49982-1e90-458b-800f-7376e11f79c2",
    "Content-Type": "application/json",
},
json={
    "customer_id": "5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42",
},
)
print(response.status_code, response.json())
{
  "data": {
    "status": "success",
    "kyc_level": "basic",
    "review_status": "pending",
    "external_user_id": "5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42",
    "web_url": "https://<verification-host>/websdk/p/aBcD123"
  },
  "error": null
}

Show web_url to Ada. The link is valid for 1 hour. Wait for customer.kyc.status.completed, or read the customer until basic is completed. This route sends no customer.kyc.status.pending event.

2. Activate cards for Ada

This call opens Ada's card application and sends her verification with it.

curl --request POST \
 --url https://api.gravv.xyz/v1/risk/features/activate \
 --header 'Api-Key: <API_KEY>' \
 --header 'Idempotency-Key: fbb8971a-3141-41c1-a076-a9f4f811887e' \
 --header 'Content-Type: application/json' \
 --data '
{
"customer_id": "5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42",
"feature_id": "virtual_cards",
"provider_data": {
"annual_remuneration": 24000,
"estimated_monthly_limit": 300,
"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": "fbb8971a-3141-41c1-a076-a9f4f811887e",
"Content-Type": "application/json",
},
body: JSON.stringify({
"customer_id": "5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42",
"feature_id": "virtual_cards",
"provider_data": {
  "annual_remuneration": 24000,
  "estimated_monthly_limit": 300,
  "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": "fbb8971a-3141-41c1-a076-a9f4f811887e",
    "Content-Type": "application/json",
},
json={
    "customer_id": "5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42",
    "feature_id": "virtual_cards",
    "provider_data": {
        "annual_remuneration": 24000,
        "estimated_monthly_limit": 300,
        "account_id": "9b40be8d-9d74-4d2a-8897-dea3ef80ad23",
    },
},
)
print(response.status_code, response.json())
{
  "data": {
    "customer_id": "5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42",
    "feature_id": "virtual_cards",
    "status": "active",
    "activated_at": "2026-10-13T11:20:05Z"
  },
  "error": null
}
  • annual_remuneration is a JSON number of at least 1000. estimated_monthly_limit is a JSON number of at least 1.
  • status active means the application was submitted, not that a card exists. The response has no application id.
  • If the response is pending_liveness with a websdk_url, see Verify a cardholder with your own ID capture.

3. Find the application or the card

curl --request GET \
 --url 'https://api.gravv.xyz/v1/cards?customer_id=5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42' \
 --header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/cards?customer_id=5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42", {
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/cards?customer_id=5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42",
headers={
    "Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
  • Sandbox. Sandbox issued the card during step 2. You see it with status active and card_type virtual, and its daily limit is estimated_monthly_limit. Do not call POST /v1/cards unless you want a second card. Go to Top up a card.
  • Live. You see the application row with status "". Its id is the application id, and it becomes the first card's id.

4. Live only: wait for approval

Only the list of applications shows the application status. The list has no customer filter, so page through it and match customer_id.

curl --request GET \
 --url 'https://api.gravv.xyz/v1/cards/applications?items_per_page=100' \
 --header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/cards/applications?items_per_page=100", {
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/cards/applications?items_per_page=100",
headers={
    "Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())
application_status What to do
approved Go to step 5
needs_verification, needs_information Show application_link to Ada, then wait for cards.application.update
pending, manual_review Wait for cards.application.update
denied, locked, canceled Stop and contact support

Never poll GET /v1/cards/applications/{application_id} for approval. Its status is the card's status, and it stays "" until a card is issued.

5. Live only: issue the card

curl --request POST \
 --url https://api.gravv.xyz/v1/cards \
 --header 'Api-Key: <API_KEY>' \
 --header 'Idempotency-Key: 63896683-7c1f-4109-ae3d-10a2be33e758' \
 --header 'Content-Type: application/json' \
 --data '
{
"customer_id": "5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42",
"card_type": "virtual",
"card_limit": 300,
"name_on_card": "Ada Obi"
}
'
const response = await fetch("https://api.gravv.xyz/v1/cards", {
method: "POST",
headers: {
"Api-Key": process.env.GRAVV_API_KEY,
"Idempotency-Key": "63896683-7c1f-4109-ae3d-10a2be33e758",
"Content-Type": "application/json",
},
body: JSON.stringify({
"customer_id": "5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42",
"card_type": "virtual",
"card_limit": 300,
"name_on_card": "Ada Obi"
}),
});
console.log(response.status, await response.json());
import os

import requests

response = requests.post(
"https://api.gravv.xyz/v1/cards",
headers={
    "Api-Key": os.environ["GRAVV_API_KEY"],
    "Idempotency-Key": "63896683-7c1f-4109-ae3d-10a2be33e758",
    "Content-Type": "application/json",
},
json={
    "customer_id": "5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42",
    "card_type": "virtual",
    "card_limit": 300,
    "name_on_card": "Ada Obi",
},
)
print(response.status_code, response.json())
{
  "data": {
    "message": "Card creation request is processing",
    "status": true,
    "id": "a14ec356-f33d-4384-ba68-4d9bfa19765b",
    "fee_charged": false
  },
  "error": null
}

card_limit is a daily limit in whole US dollars. Gravv sets a 4-digit PIN for you.

6. Confirm

curl --request GET \
 --url https://api.gravv.xyz/v1/cards/a14ec356-f33d-4384-ba68-4d9bfa19765b \
 --header 'Api-Key: <API_KEY>'
const response = await fetch("https://api.gravv.xyz/v1/cards/a14ec356-f33d-4384-ba68-4d9bfa19765b", {
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/cards/a14ec356-f33d-4384-ba68-4d9bfa19765b",
headers={
    "Api-Key": os.environ["GRAVV_API_KEY"],
},
)
print(response.status_code, response.json())

Check that status is active and spending_controls.daily_transaction_limit is 300. Next, top up the card.

What can go wrong

Response Why Fix
400 Failed to check feature eligibility The feature_id is unknown, there is no verification record, or a brief fault occurred Send virtual_cards, and retry with a new key
Ada was completed and becomes pending You called POST /v1/risk/start-kyc for a verified person Call it only when basic is not_started or failed. Ada must finish the new link
customer.kyc.status.failed with failure_reason The documents or selfie were rejected Start verification again and give Ada the new web_url
400 failed to check liveness requirement: customer KYC must be completed before activating cards feature Verification has not finished Wait for customer.kyc.status.completed
400 cards service: annual_remuneration must be a number of at least 1000 The value is missing or too small Send a number of at least 1000
400 cards service: estimated_monthly_limit must be a number of at least 1 The value is missing or 0 Send a number of at least 1
400 cards service: account must be a card account, but this account's type is "regular" The account is not your card account Send your card account id
400 cards service: account must be active to be used for cards, but its status is "pending" The card account is not ready Wait until it is active
400 cards service: internal server error You left out account_id or sent a number as a string, or the application was refused Send account_id, and send numbers as JSON numbers. If the error persists, contact support with the customer id
200 with message Feature activation already exists with status: active Cards are already activated for this customer Find the card with GET /v1/cards?customer_id=…
400 card application is not yet approved! In live, the application is still pending Wait for approved, then retry with a new key
400 card application not found! Activation never succeeded Run step 2
400 CardLimit is required / NameOnCard is required A field is missing. The message uses internal field names Send it with a new key
403 feature_access_required Card issuing is not switched on for your business Request access

Webhooks you will receive

  1. customer.kyc.status.completed, or customer.kyc.status.failed with failure_reason.
  2. In live, cards.application.update with event_data.application.status.
  3. No webhook arrives when a card is issued. Read the card with GET /v1/cards.

All events for one card share event_group_id, which is the card id. Deduplicate on event_id.

Testing in sandbox

  • Sandbox approves activation at once and issues the virtual card during step 2. Steps 4 and 5 do not apply, and application_link is always "".
  • Sandbox KYC does not complete today. A new customer's basic KYC stays pending, so activation returns customer KYC must be completed. Customers verified earlier still activate and get a card.

Reference

Was this page helpful?