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.
- You issue driver Tunde a virtual card with a 200 USD daily limit.
- 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.
- 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.
- 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. To print the company's name on the card instead, see 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.
- For the money, call
GET /v2/accounts/{id}on the company account. It returnsbalance,blockchain_networkandstatusactive. After issuance,GET /v1/cards?card_id=…returns the card'sblockchain_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, thentransfer.status.completed(workflow_typecard_funding) andcards.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.