Developers/Build
Catalog and orders
This guide walks through the whole buying flow: check your balance, find a product, get its price, place an order, and read the codes.
Related: Authentication · Conventions · Webhooks
#Account and balance
#Account
GET/api/v1/account shows your company details and whether the API is switched on.
{
"merchant_id": "M00000001",
"name": "Acme Shop",
"legal_name": "Acme Shop Ltd",
"tier": "standard",
"billing_email": "[email protected]",
"status": "active",
"kyb_status": "approved",
"api_access_enabled": true,
"default_currency": "USD"
}default_currencyis the currency of your wallet. All prices you pay are in it.api_access_enabledistrueonce CardV has approved your business.
#Balance
GET/api/v1/balance shows how much money you can spend.
{
"currency": "USD",
"balance": "1520.4000",
"reserved_amount": "0.0000",
"available_balance": "1520.4000",
"low_balance_threshold": "200.0000",
"low_balance_notified_at": null,
"is_active": true
}available_balanceis what you can spend now. It isbalanceminusreserved_amount.- An order larger than
available_balanceis rejected, and nothing is charged. low_balance_thresholdis the level for the low-balance email. You set it in the Portal.- To add money, use the Portal.
#Products (SKUs)
A SKU is one product you can buy, for example "Steam Wallet 10 USD".
Its ID looks like S000456. You quote and order SKUs.
GET/api/v1/skus lists the SKUs you can buy. Example:
GET /api/v1/skus?search=steam®ion=US&limit=50&offset=0Filters (all optional):
| Filter | Example | Matches |
|---|---|---|
search | steam | SKU ID, name or brand |
brand | Steam | Brand name (any case) |
region | US | Country code or country name |
vertical | gift_card | Product line |
product_type | pin_code | How it is delivered |
Paging: send limit (default 100, max 500) and offset.
Keep asking with a higher offset until offset reaches count.
{
"count": 7,
"limit": 1,
"results": [
{
"sku_id": "S000456",
"product_id": "P000123",
"name": "Steam Wallet 10 USD",
"product_name": "Steam Wallet US",
"brand": "Steam",
"region": "US",
"vertical": "gift_card",
"product_type": "pin_code",
"denomination_type": "fixed",
"denomination_value": "10.0000",
"face_currency": "USD",
"merchant_price": "9.2500",
"settlement_currency": "USD",
"availability": "available",
"min_quantity": 1,
"max_quantity": 100,
"required_input_schema": [],
"...": "more fields"
}
],
"filter_options": {"brands": [], "regions": [], "verticals": []}
}GET/api/v1/skus/{sku_id} returns one SKU with the same fields.
The most useful fields:
| Field | Meaning |
|---|---|
sku_id | The ID you use to quote and order. |
merchant_price | Your price per unit, in settlement_currency. |
availability | available or unavailable. Only order available SKUs. |
denomination_type | fixed or range. See fixed and range amounts. |
face_currency | Currency printed on the card. May differ from your wallet. |
min_quantity, max_quantity | How many units one order line may have. |
product_type | pin_code (you get a code) or direct_charge (we top up an account). |
required_input_schema | Details you must send for direct top-ups. |
brand_logo_url, image_url | Images hosted by CardV, or "". |
description, redemption_instructions, terms | Text you can show your customers. |
Tips:
- You only see SKUs that are active and open to your account. Other SKUs return 404.
- Sync the SKU list every 5–15 minutes. Always get a quote right before you order.
filter_optionslists the brands, regions and product lines you can filter by.
#Fixed and range amounts
Most SKUs have a fixed face value, such as 10 USD. Some SKUs have a range: your customer chooses the amount, such as 5 to 500 USD.
| Type | When quoting | When ordering |
|---|---|---|
fixed | Send quantity | Leave out amount |
range | Send quantity and amount | Send amount |
For a range SKU, amount must be between min_face_value and max_face_value.
It is in face_currency.
#Direct top-ups
Some products top up your customer's account directly, for example a game account. For these, CardV needs the account details. The SKU lists them:
"required_input_schema": [
{"key": "player_id", "label": "Player ID", "required": true},
{"key": "server", "label": "Server", "required": false}
]Send the values in the order line's inputs, using each key:
"inputs": {"player_id": "123456789", "server": "EU"}- A field is required unless it says
"required": false. - If a required value is missing, the order is rejected with an
itemserror. - These values are your customer's personal data. Protect them (see Security).
#Price quote
A quote tells you the current price for a quantity.
GET /api/v1/skus/S000456/quote?quantity=2
GET /api/v1/skus/S000789/quote?quantity=1&amount=25.00{
"sku_id": "S000456",
"settlement_currency": "USD",
"merchant_price": "9.2500",
"quantity": 2,
"total_price": "18.5000",
"min_quantity": 1,
"max_quantity": 100,
"availability": "available"
}- A quote does not hold the price. Prices can change at any time.
- To protect yourself, send
merchant_priceasexpected_unit_pricewhen you order. If the price changed, CardV rejects the order and charges nothing. - A quantity or amount out of range returns HTTP 400 with a
quantityoramounterror.
#Place an order
POST/api/v1/orders buys one or more SKUs and pays from your wallet.
This call must be signed.
{
"external_order_id": "SHOP-20260929-10001",
"items": [
{"sku_id": "S000456", "quantity": 2, "expected_unit_price": "9.2500"},
{"sku_id": "S000789", "amount": "25.00", "expected_unit_price": "23.7500"},
{
"sku_id": "S000900",
"expected_unit_price": "4.9000",
"inputs": {"player_id": "123456789"}
}
]
}| Field | Required | Meaning |
|---|---|---|
external_order_id | Yes | Your order number, 1–120 characters. Must be unique. |
items | Yes | One or more order lines. |
items[].sku_id | Yes | The SKU to buy. |
items[].quantity | No | How many. Default 1. |
items[].amount | Range SKUs | The face value to buy. |
items[].expected_unit_price | Recommended | The quoted merchant_price. Always send it. |
items[].inputs | Direct top-ups | Account details for direct top-ups. |
When CardV accepts the order, it charges the full total to your wallet at once. Delivery then starts in the background.
The response is HTTP 201:
{
"idempotent_replay": false,
"order": {
"order_id": "O-00001234",
"external_order_id": "SHOP-20260929-10001",
"status": "accepted",
"total_amount": "46.2000",
"...": "more fields"
}
}Save order.order_id. This response never includes codes. Read them later (Read an order).
#Rejected orders
A rejected order returns HTTP 400 and charges nothing. The error key tells you why:
| Key | Cause | What to do |
|---|---|---|
items | Price changed, SKU unavailable, bad amount or missing input | Get a new quote, fix it, send again |
balance | Not enough money in your wallet | Add funds in the Portal |
risk | Over your order size or daily limit | Contact CardV |
external_order_id | Your order number was already used for a different order | See Retry safely |
Example of a price change:
{
"items": "SKU S000456 price changed from 9.2500 to 9.4100 USD; refresh quote and confirm again."
}Your account tier sets the limits on one order, on daily spend and on daily order count. Daily limits reset at 00:00 UTC. Ask CardV for your limits.
#Retry safely
Your order number (external_order_id) protects you from buying twice.
If you send the same order again with the same order number, CardV does not charge you again.
It returns the order it already has.
| You send | You get |
|---|---|
| A new order number | HTTP 201. A new order. Your wallet is charged. |
| The same order number and the same order | HTTP 200 and "idempotent_replay": true. The existing order. No charge. |
| The same order number but a different order | HTTP 400 on external_order_id. Nothing happens. |
"The same order" means the same lines, in the same sequence, with the same SKU, quantity, amount and inputs.
If you send expected_unit_price, it must match the price of the first order.
A repeated order is recognised before balance and price checks. So it always returns the first order, even if the price has changed since.
#Safe retry flow
If you do not get a clear answer, just send the same order again.
POST /orders with order number R
├─ 201 or 200 → save order_id. Done.
├─ 400 items / balance / risk → no order was made.
│ Fix the cause and send again. You may reuse R.
├─ 400 external_order_id → R belongs to a different order. Stop and check.
├─ 403 signature error → sign again and send the same body.
├─ 429 → wait for Retry-After, sign again, send the same body.
└─ timeout, 5xx or lost connection
→ send the same body again with the same R.
You get 201 (the first try did not arrive) or 200 (it did).Rules:
- Never make a new order number because a response was lost. If the first request did arrive, a new number would buy everything twice.
- Every resend needs a new timestamp, nonce and signature. The body stays the same.
#Read an order
GET/api/v1/orders/{order_id} returns the order, its status and its codes.
{
"order_id": "O-00001234",
"external_order_id": "SHOP-20260929-10001",
"status": "succeeded",
"currency": "USD",
"total_amount": "18.5000",
"created_at": "2026-09-29T08:15:30.123456Z",
"updated_at": "2026-09-29T08:15:41.004211Z",
"items": [
{
"sku_id": "S000456",
"product_name": "Steam Wallet US",
"quantity": 2,
"unit_price": "9.2500",
"total_price": "18.5000",
"delivery_count": 2,
"deliveries": [{"...": "see Codes below"}]
}
],
"...": "more fields"
}total_amountis what you were charged when the order was accepted.items[].unit_priceis the price locked for this order.items[].deliveriesholds the full codes. Treat the response as secret.invoice_urlanddelivery_file_urlare paths to the invoice and to the codes as CSV. Both are Portal only. With an API key they return HTTP 403.- Also returned:
id(an old number, do not use it),events(a history for display only), and per-line delivery progress. You can ignore these. - An unknown order ID returns HTTP 404.
#Order status and codes
#Status
accepted ──► processing ──► succeeded
│
├──► partially_succeeded (some lines delivered, some not)
│
└──► failed ──► refunded (money returned to your wallet)| Status | Finished? | What to do |
|---|---|---|
accepted | No | Wait. The wallet is charged, delivery has not started. |
processing | No | Wait. Do not place the order again. |
succeeded | Yes | Read the codes and give them to your customer. |
partially_succeeded | Yes | Deliver what arrived. The rest is refunded later. |
failed | Not yet | Wait for refunded. Failed is not yet a refund. |
refunded | Yes | The money is back in your wallet. |
If you do not use webhooks, poll like this: after 5 seconds, then 10 s, 30 s, 60 s, then every 5 minutes. Stay within the rate limit. Most orders finish in seconds. Some need a manual check and can take hours.
#Codes
Each delivered unit is one object in items[].deliveries:
{
"status": "stored",
"delivery_type": "card_pin",
"display_fields": [
{"key": "card_number", "label": "Card number", "value": "X1234", "kind": "secret"},
{"key": "pin_code", "label": "PIN", "value": "9876", "kind": "secret"}
],
"redeem_url": "",
"expiry_date": "2027-09-29",
"instructions": "Redeem at ...",
"is_masked": false
}- Show your customer the
display_fields: each has alabeland avalue. Also showredeem_url,expiry_dateandinstructionswhen they are not empty. kindissecretfor codes and PINs, andreferencefor things like serial numbers.delivery_typesays what you got:code,card_pin,link,code_linkorqr. New types may appear, so always build fromdisplay_fields.- For
linkdeliveries, theredeem_urlitself is the code. Keep it secret. - Never give your customer a unit whose
statusisvoided. - Direct top-up products usually have no deliveries.
succeededmeans the account was topped up. - Copies such as
card_numberandpin_codealso appear as separate fields. They may be empty. - Webhooks never contain codes. Read the order after a webhook arrives.
Questions about your integration? Email [email protected] with your Merchant ID and the order or request ID.