---
title: "Issue a virtual card after a hosted check"
description: "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](/recipes/cards/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](/platform/cards/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](/recipes/customers-and-kyc/overview).
- 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**.

<CodeGroup>
```bash title="cURL"
curl --request GET \
     --url https://api.gravv.xyz/v1/customers/5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42 \
     --header 'Api-Key: <API_KEY>'
```
```javascript title="Node"
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());
```
```python title="Python"
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())
```
</CodeGroup>

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

<CodeGroup>
```bash title="cURL"
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"
}
'
```
```javascript title="Node"
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());
```
```python title="Python"
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())
```
</CodeGroup>

| 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.

<CodeGroup>
```bash title="cURL"
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"
}
'
```
```javascript title="Node"
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());
```
```python title="Python"
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())
```
</CodeGroup>

```json title="Response"
{
  "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.

<CodeGroup>
```bash title="cURL"
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"
  }
}
'
```
```javascript title="Node"
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());
```
```python title="Python"
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())
```
</CodeGroup>

```json title="Response"
{
  "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](/recipes/cards/verify-a-cardholder-with-your-own-id-capture#4-finish-the-liveness-check-and-activate-again).

### 3. Find the application or the card

<CodeGroup>
```bash title="cURL"
curl --request GET \
     --url 'https://api.gravv.xyz/v1/cards?customer_id=5b0f6c1e-2a8f-4c39-9d0e-0c7e3b9a1f42' \
     --header 'Api-Key: <API_KEY>'
```
```javascript title="Node"
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());
```
```python title="Python"
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())
```
</CodeGroup>

- **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](/recipes/cards/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`.

<CodeGroup>
```bash title="cURL"
curl --request GET \
     --url 'https://api.gravv.xyz/v1/cards/applications?items_per_page=100' \
     --header 'Api-Key: <API_KEY>'
```
```javascript title="Node"
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());
```
```python title="Python"
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())
```
</CodeGroup>

| `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

<CodeGroup>
```bash title="cURL"
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"
}
'
```
```javascript title="Node"
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());
```
```python title="Python"
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())
```
</CodeGroup>

```json title="Response"
{
  "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

<CodeGroup>
```bash title="cURL"
curl --request GET \
     --url https://api.gravv.xyz/v1/cards/a14ec356-f33d-4384-ba68-4d9bfa19765b \
     --header 'Api-Key: <API_KEY>'
```
```javascript title="Node"
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());
```
```python title="Python"
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())
```
</CodeGroup>

Check that `status` is `active` and `spending_controls.daily_transaction_limit` is `300`. Next, [top up the card](/recipes/cards/top-up-a-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

- [Set up your card account](/platform/cards/set-up-your-card-account)
- [Card KYC and activation](/platform/cards/card-kyc/overview)
- [Activate a feature](/api-reference/features/post-v1-risk-features-activate)
- [Create a card](/api-reference/cards/post-v1-cards)
