Issue expense cards to employees

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

Find out what is required

  • For each employee, check eligibility.
  • 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.

2. Activate with a balance of its own

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"
}
}
}
'
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());
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())

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. In live, issue the card with "card_limit": 200.

4. Top up from the company account

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"
}
'
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());
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())

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.

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 Follow the fix given there

Webhooks you will receive

  • For each employee, you receive the events listed in the hosted-check recipe.
  • 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

Was this page helpful?