---
title: "Issue expense cards to employees"
description: "Give each employee of a company you serve a virtual USD card with its own balance and daily limit, topped up from the company's account."
---
This recipe gives each employee of a company you serve a virtual USD card with its own balance and daily limit. You top up each card from the company's account.

**Who it is for.** An expense-management platform that serves a Nairobi logistics company. The company gives each driver a USD card for fuel and tolls and tops it up weekly.

**Status.** Available.

## Money example

The company's USD account holds 5,000 USD.

1. You issue driver Tunde a virtual card with a 200 USD daily limit.
2. Every Monday you move 500 USD from the company account to Tunde's card. The company account now holds 4,500 USD and the card holds 500 USD.
3. Tunde pays 64.20 USD for fuel. The card now holds 435.80 USD.

## Before you start

- Everything in [Issue a virtual card after a hosted check](/recipes/cards/issue-a-virtual-card-after-a-hosted-check#before-you-start).
- Each employee is an **individual** customer of yours, with their own phone and address, verified as in that recipe or [with your own ID capture](/recipes/cards/verify-a-cardholder-with-your-own-id-capture). To print the company's name on the card instead, see [Issue a business card](/recipes/cards/issue-a-business-card).
- A funded, wallet-backed company account on the same network as the cards.

## Find out what is required

- For each employee, [check eligibility](/recipes/cards/issue-a-virtual-card-after-a-hosted-check#find-out-what-is-required).
- For the money, call `GET /v2/accounts/{id}` on the company account. It returns `balance`, `blockchain_network` and `status` `active`. After issuance, `GET /v1/cards?card_id=…` returns the card's `blockchain_network`.

## Steps

### 1. Verify the employee

Verify the employee as in step 1 of [the hosted-check recipe](/recipes/cards/issue-a-virtual-card-after-a-hosted-check#1-start-hosted-verification).

### 2. Activate with a balance of its own

<CodeGroup>
```bash title="cURL"
curl --request POST \
     --url https://api.gravv.xyz/v1/risk/features/activate \
     --header 'Api-Key: <API_KEY>' \
     --header 'Idempotency-Key: 186d8ea1-59fc-4eec-887f-cc46915f5984' \
     --header 'Content-Type: application/json' \
     --data '
{
  "customer_id": "c3a1f2e4-7b6d-4e8a-9c0b-1d2e3f4a5b6c",
  "feature_id": "virtual_cards",
  "provider_data": {
    "annual_remuneration": 18000,
    "estimated_monthly_limit": 200,
    "account_id": "9b40be8d-9d74-4d2a-8897-dea3ef80ad23",
    "funding": {
      "mode": "own"
    }
  }
}
'
```
```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": "186d8ea1-59fc-4eec-887f-cc46915f5984",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "customer_id": "c3a1f2e4-7b6d-4e8a-9c0b-1d2e3f4a5b6c",
    "feature_id": "virtual_cards",
    "provider_data": {
      "annual_remuneration": 18000,
      "estimated_monthly_limit": 200,
      "account_id": "9b40be8d-9d74-4d2a-8897-dea3ef80ad23",
      "funding": {
        "mode": "own"
      }
    }
  }),
});
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": "186d8ea1-59fc-4eec-887f-cc46915f5984",
        "Content-Type": "application/json",
    },
    json={
        "customer_id": "c3a1f2e4-7b6d-4e8a-9c0b-1d2e3f4a5b6c",
        "feature_id": "virtual_cards",
        "provider_data": {
            "annual_remuneration": 18000,
            "estimated_monthly_limit": 200,
            "account_id": "9b40be8d-9d74-4d2a-8897-dea3ef80ad23",
            "funding": {
                "mode": "own",
            },
        },
    },
)
print(response.status_code, response.json())
```
</CodeGroup>

The same card account backs every employee. `funding.mode` `own` gives the card its own balance. Without `funding`, each employee still gets a balance of their own, because a balance belongs to one cardholder. `own` matters when one person holds several cards. `own` counts only on a person's first activation.

### 3. Find or issue the card

Find or issue the card as in step 3 of [the hosted-check recipe](/recipes/cards/issue-a-virtual-card-after-a-hosted-check#3-find-the-application-or-the-card). In live, issue the card with `"card_limit": 200`.

### 4. Top up from the company account

<CodeGroup>
```bash title="cURL"
curl --request POST \
     --url https://api.gravv.xyz/v1/transfer \
     --header 'Api-Key: <API_KEY>' \
     --header 'Idempotency-Key: f31e3ef4-5c2f-4042-8551-d7535256dc63' \
     --header 'Content-Type: application/json' \
     --data '
{
  "source": {
    "source_type": "internal_account",
    "id": "6f1c2b8e-9a43-4f0e-8d35-2b7f0f6c1a90"
  },
  "destination": {
    "destination_type": "card",
    "id": "e7b1c2d3-4f5a-4b6c-8d7e-9f0a1b2c3d4e"
  },
  "amount": "500",
  "customer_id": "0d6c2f4e-6a1b-4f0e-9b8f-5c2a7d3e1f00",
  "client_reference": "fuel-tunde-2026-w42",
  "description": "Weekly fuel allowance"
}
'
```
```javascript title="Node"
const response = await fetch("https://api.gravv.xyz/v1/transfer", {
  method: "POST",
  headers: {
    "Api-Key": process.env.GRAVV_API_KEY,
    "Idempotency-Key": "f31e3ef4-5c2f-4042-8551-d7535256dc63",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "source": {
      "source_type": "internal_account",
      "id": "6f1c2b8e-9a43-4f0e-8d35-2b7f0f6c1a90"
    },
    "destination": {
      "destination_type": "card",
      "id": "e7b1c2d3-4f5a-4b6c-8d7e-9f0a1b2c3d4e"
    },
    "amount": "500",
    "customer_id": "0d6c2f4e-6a1b-4f0e-9b8f-5c2a7d3e1f00",
    "client_reference": "fuel-tunde-2026-w42",
    "description": "Weekly fuel allowance"
  }),
});
console.log(response.status, await response.json());
```
```python title="Python"
import os

import requests

response = requests.post(
    "https://api.gravv.xyz/v1/transfer",
    headers={
        "Api-Key": os.environ["GRAVV_API_KEY"],
        "Idempotency-Key": "f31e3ef4-5c2f-4042-8551-d7535256dc63",
        "Content-Type": "application/json",
    },
    json={
        "source": {
            "source_type": "internal_account",
            "id": "6f1c2b8e-9a43-4f0e-8d35-2b7f0f6c1a90",
        },
        "destination": {
            "destination_type": "card",
            "id": "e7b1c2d3-4f5a-4b6c-8d7e-9f0a1b2c3d4e",
        },
        "amount": "500",
        "customer_id": "0d6c2f4e-6a1b-4f0e-9b8f-5c2a7d3e1f00",
        "client_reference": "fuel-tunde-2026-w42",
        "description": "Weekly fuel allowance",
    },
)
print(response.status_code, response.json())
```
</CodeGroup>

`customer_id` is the company, which owns the source account. The card still belongs to Tunde. Follow the transfer as in [Top up a card](/recipes/cards/top-up-a-card#2-wait-for-the-transfer-to-complete).

### 5. Each week

Read the card's balance (`GET /v1/cards/{card_id}/balance`), then top up the difference with a new `Idempotency-Key`.

## What can go wrong

| Response | Why | Fix |
| :-- | :-- | :-- |
| 400 `customer already holds the maximum of 10 cards` | One person can hold at most 10 cards. Blocked cards do not count | Block unused cards |
| 400 `this card's balance is a new provider account whose application is pending; retry with the same Idempotency-Key once it is approved` | In live, an `own` balance needs its own approval | Wait, then retry with the **same** key and body. Gravv replays that 400 for 24 hours, so a same-key retry works only once 24 hours have passed. A new key starts a new card and a new balance. If 24 hours is too long, contact support |
| 400 `on this card provider a card can only share a balance with the same cardholder's cards` | You put two employees on one balance | Give each employee their own balance |
| 400 `funding.card_group_id is only valid when funding.mode is "shared"` | You sent `card_group_id` with `own` | Remove `card_group_id` |
| Funding errors | Listed in [Top up a card](/recipes/cards/top-up-a-card#what-can-go-wrong) | Follow the fix given there |

## Webhooks you will receive

- For each employee, you receive the events listed in [the hosted-check recipe](/recipes/cards/issue-a-virtual-card-after-a-hosted-check#webhooks-you-will-receive).
- For each top-up, you receive `transfer.status.pending`, then `transfer.status.completed` (`workflow_type` `card_funding`) and `cards.transaction.funding`. Gravv does not guarantee these events, so poll the transaction.
- For each payment, you receive `cards.transaction.payment`.

## Testing in sandbox

Sandbox issues the card at activation, with a daily limit equal to `estimated_monthly_limit`. A funding transfer completes when its `customer_id` is the owner of the source account rather than the cardholder. If you send the activate request again with a new key, the API returns `Feature activation already exists with status: active`.

## Reference

- [Create a card](/platform/cards/create-a-card)
- [Fund cards](/platform/cards/fund-cards)
- [Check card balance](/platform/cards/check-card-balance)
