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.

JSON
{
  "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_currency is the currency of your wallet. All prices you pay are in it.
  • api_access_enabled is true once CardV has approved your business.

#Balance

GET/api/v1/balance shows how much money you can spend.

JSON
{
  "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_balance is what you can spend now. It is balance minus reserved_amount.
  • An order larger than available_balance is rejected, and nothing is charged.
  • low_balance_threshold is 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:

HTTP
GET /api/v1/skus?search=steam&region=US&limit=50&offset=0

Filters (all optional):

FilterExampleMatches
searchsteamSKU ID, name or brand
brandSteamBrand name (any case)
regionUSCountry code or country name
verticalgift_cardProduct line
product_typepin_codeHow it is delivered

Paging: send limit (default 100, max 500) and offset. Keep asking with a higher offset until offset reaches count.

JSON
{
  "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:

FieldMeaning
sku_idThe ID you use to quote and order.
merchant_priceYour price per unit, in settlement_currency.
availabilityavailable or unavailable. Only order available SKUs.
denomination_typefixed or range. See fixed and range amounts.
face_currencyCurrency printed on the card. May differ from your wallet.
min_quantity, max_quantityHow many units one order line may have.
product_typepin_code (you get a code) or direct_charge (we top up an account).
required_input_schemaDetails you must send for direct top-ups.
brand_logo_url, image_urlImages hosted by CardV, or "".
description, redemption_instructions, termsText 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_options lists 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.

TypeWhen quotingWhen ordering
fixedSend quantityLeave out amount
rangeSend quantity and amountSend 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:

JSON
"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:

JSON
"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 items error.
  • These values are your customer's personal data. Protect them (see Security).

#Price quote

A quote tells you the current price for a quantity.

HTTP
GET /api/v1/skus/S000456/quote?quantity=2
GET /api/v1/skus/S000789/quote?quantity=1&amount=25.00
JSON
{
  "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_price as expected_unit_price when 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 quantity or amount error.

#Place an order

POST/api/v1/orders buys one or more SKUs and pays from your wallet. This call must be signed.

JSON
{
  "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"}
    }
  ]
}
FieldRequiredMeaning
external_order_idYesYour order number, 1–120 characters. Must be unique.
itemsYesOne or more order lines.
items[].sku_idYesThe SKU to buy.
items[].quantityNoHow many. Default 1.
items[].amountRange SKUsThe face value to buy.
items[].expected_unit_priceRecommendedThe quoted merchant_price. Always send it.
items[].inputsDirect top-upsAccount 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:

JSON
{
  "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:

KeyCauseWhat to do
itemsPrice changed, SKU unavailable, bad amount or missing inputGet a new quote, fix it, send again
balanceNot enough money in your walletAdd funds in the Portal
riskOver your order size or daily limitContact CardV
external_order_idYour order number was already used for a different orderSee Retry safely

Example of a price change:

JSON
{
  "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 sendYou get
A new order numberHTTP 201. A new order. Your wallet is charged.
The same order number and the same orderHTTP 200 and "idempotent_replay": true. The existing order. No charge.
The same order number but a different orderHTTP 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.

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

JSON
{
  "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_amount is what you were charged when the order was accepted.
  • items[].unit_price is the price locked for this order.
  • items[].deliveries holds the full codes. Treat the response as secret.
  • invoice_url and delivery_file_url are 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

Text
accepted ──► processing ──► succeeded
                  │
                  ├──► partially_succeeded   (some lines delivered, some not)
                  │
                  └──► failed ──► refunded   (money returned to your wallet)
StatusFinished?What to do
acceptedNoWait. The wallet is charged, delivery has not started.
processingNoWait. Do not place the order again.
succeededYesRead the codes and give them to your customer.
partially_succeededYesDeliver what arrived. The rest is refunded later.
failedNot yetWait for refunded. Failed is not yet a refund.
refundedYesThe 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:

JSON
{
  "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 a label and a value. Also show redeem_url, expiry_date and instructions when they are not empty.
  • kind is secret for codes and PINs, and reference for things like serial numbers.
  • delivery_type says what you got: code, card_pin, link, code_link or qr. New types may appear, so always build from display_fields.
  • For link deliveries, the redeem_url itself is the code. Keep it secret.
  • Never give your customer a unit whose status is voided.
  • Direct top-up products usually have no deliveries. succeeded means the account was topped up.
  • Copies such as card_number and pin_code also 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.