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",
  "billing_email": "[email protected]",
  "status": "active",
  "kyb_status": "approved",
  "api_access_enabled": true,
  "default_currency": "USD",
  "purchase_limits": {
    "currency": "USD",
    "verified": true,
    "max_order_amount": "1000.00",
    "daily_order_amount_limit": "10000.00",
    "daily_order_count_limit": 1000,
    "used_today_amount": "120.50",
    "used_today_count": 3,
    "day_resets_at": "2026-10-01T00:00:00+00:00"
  }
}
  • 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.40",
  "reserved_amount": "0.00",
  "available_balance": "1520.40",
  "low_balance_threshold": "200.00",
  "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.

#Exchange rate

GET/api/v1/fx-rates?quote=EUR gives the rate from USD to another currency.

Use it if your store sells in a currency other than USD. CardV always charges your wallet in USD. The rate only helps you set your own prices.

JSON
{
  "base": "USD",
  "quote": "EUR",
  "rate": "0.9234",
  "as_of": "2026-10-11T07:05:00Z"
}
  • quote is an ISO 4217 currency code, such as EUR, GBP or JPY.
  • base is optional. Only USD is supported.
  • rate is how many units of quote you get for 1 USD. It is a decimal string. Do not read it as a float.
  • as_of is when CardV last updated this rate. Rates are updated every hour.
  • quote=USD returns "rate": "1".
  • Save the rate on your side. Asking a few times a day is enough.
StatusMeaningWhat to do
400quote is missing or is not a 3-letter code, or base is not USDFix the request
404No exchange rate for this currency.Set prices in this currency yourself
503Exchange rate temporarily unavailable.Keep using the last rate you saved, and try again later

Sign this request like any other. The signed path includes the query string, for example /api/v1/fx-rates?quote=EUR.

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

#Product models

Every SKU is one of these models. Read denomination_type and product_type to tell them apart.

ModelHow to recognise itPriceOrderYou receive
Fixed amountdenomination_type: "fixed"merchant_price is the price of one unit.Send quantity.Codes in deliveries.
Range amountdenomination_type: "range"merchant_price_min to merchant_price_max. The real price depends on the amount: quote it.Send quantity and amount.Codes in deliveries.
Direct top-upproduct_type: "direct_charge"Same as fixed.Also send inputs (see direct top-ups).Usually no codes. succeeded means the account was topped up.
Mobile rechargeNot in this catalogIts own quoteIts own APISee Mobile recharge.
  • vertical is the product line, not the model. Direct top-ups can still have vertical: "gift_card".
  • Any fixed or range SKU can be delivered as a CardV redemption link instead of a code. The SKU then has delivery_mode: "voucher". You receive a link (delivery_type: "link", the link is in redeem_url). Your customer opens it to get the code. Send it to your customer like a code.

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. GLC = global, EU = Europe
categoryTravelBusiness category, for example Travel, Digital Wallets & Payment, Game Credits
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": 2,
  "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",
      "face_currency": "USD",
      "merchant_price": "9.25",
      "merchant_price_min": "9.25",
      "merchant_price_max": "9.25",
      "settlement_currency": "USD",
      "availability": "available",
      "min_quantity": 1,
      "max_quantity": 100,
      "required_input_schema": [],
      "...": "more fields"
    },
    {
      "sku_id": "S006126",
      "product_id": "P002210",
      "name": "Apple ES",
      "product_name": "Apple ES",
      "brand": "Apple",
      "region": "ES",
      "vertical": "gift_card",
      "product_type": "pin_code",
      "denomination_type": "range",
      "denomination_value": "0",
      "min_face_value": "2",
      "max_face_value": "250",
      "amount_step": null,
      "face_currency": "EUR",
      "merchant_price": "2.28",
      "merchant_price_min": "2.28",
      "merchant_price_max": "284.15",
      "price_label": "From 2.28 USD",
      "settlement_currency": "USD",
      "availability": "available",
      "...": "more fields"
    }
  ],
  "filter_options": {"brands": [], "regions": [], "categories": [], "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. For a range SKU this is only the price of min_face_value, a starting price. Do not use it for other amounts: get a quote with amount.
availabilityavailable or unavailable. Only order available SKUs.
denomination_typefixed or range. See fixed and range amounts.
denomination_valueFace value of a fixed SKU. "0" for a range SKU. Also "0", with an empty face_currency, when the SKU is defined by its label instead of a face value (plans, passes, packs, in-game amounts such as 60 UC). Show label for these SKUs.
min_face_value, max_face_valuerange SKUs: the lowest and highest amount your customer can choose, in face_currency.
merchant_price_min, merchant_price_maxPrice of the lowest and highest amount. Equal to merchant_price for fixed SKUs. Use them to show a price range; always quote the exact amount before you order. merchant_price_max is "" when it cannot be priced right now.
price_labelmerchant_price as display text. For range SKUs it starts with From, for example From 2.28 USD.
face_value_labelFace value as display text, for example 10 USD or 50 - 1000 GBP. For a SKU defined by its label, this is the label, for example Prime (1 Month).
discountDiscount on the face value, for example 7.50% off face value. Only for fixed SKUs whose face currency is your wallet currency; otherwise "".
amount_stepRange SKUs only. The amount must be a multiple of this value, for example "1" means whole numbers only. null means no step.
face_currencyCurrency printed on the card. May differ from your wallet. For in-game top-ups it is the in-game unit, for example UC, Diamonds or Delta Coins, and denomination_value is the amount of that unit (60 and UC for 60 UC).
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).
delivery_typeWhat the supplier delivers: code, code_pin, code_serial, url or direct_charge. When delivery_mode is voucher, you get a link instead.
delivery_modecode (you get the code itself) or voucher (you get a CardV redemption link).
voucher_instructionsFor voucher SKUs: text that tells your customer how to use the link. Otherwise "".
vertical, categoryProduct line and business category, for filters and display.
region, region_name, region_labelCountry or area code, its name, and a display label.
required_input_schemaDetails you must send for direct top-ups.
brand_logo_url, image_urlImages hosted by CardV, or "".
description, redemption_instructions, terms, disclaimerText 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.
  • Show your customers prices from your synced list. Do not call the quote endpoint for every page view or visitor.
  • Range SKU prices follow the supplier cost and can change a few times a day. Refresh merchant_price_min and merchant_price_max with your SKU sync.
  • filter_options lists the brands, regions and product lines you can filter by.

#Sync by product

If your store shows one page per product with an amount picker, sync products instead of SKUs. GET/api/v1/products lists products. Each product carries all the SKUs you can buy in it.

HTTP
GET /api/v1/products?brand=Steam&region=GB&limit=50&offset=0

It takes the same filters and paging as GET/api/v1/skus. count counts products and sku_count counts their SKUs.

JSON
{
  "count": 1,
  "sku_count": 4,
  "limit": 50,
  "results": [
    {
      "product_id": "P000123",
      "product_name": "Steam Wallet UK",
      "brand": "Steam",
      "category": "Gaming",
      "region": "GB",
      "product_type": "pin_code",
      "image_url": "https://b2b.cardv.net/api/v1/catalog-assets/steam.png",
      "required_input_schema": [],
      "skus": [
        {
          "sku_id": "S000456",
          "name": "Steam Wallet 5 GBP",
          "label": "5 GBP",
          "denomination_type": "fixed",
          "denomination_value": "5",
          "face_currency": "GBP",
          "merchant_price": "7.61",
          "settlement_currency": "USD",
          "availability": "available",
          "min_quantity": 1,
          "max_quantity": 100,
          "...": "more fields"
        }
      ]
    }
  ],
  "filter_options": {"brands": [], "regions": [], "categories": [], "verticals": []}
}

GET/api/v1/products/{product_id} returns one product with its SKUs, plus description, redemption_instructions, terms and disclaimer.

  • Each SKU in skus has the same fields and values as in GET/api/v1/skus. Quote and order with its sku_id as usual.
  • Use label as the option text, for example 5 GBP or Tinder Plus 1 Month. Some products are plans, not amounts.
  • One product has one face currency and one amount type. A range SKU is always a product of its own.
  • The list leaves out the long texts. Read them once per product from GET/api/v1/products/{product_id}.

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

Price of a range SKU. In the SKU and product lists, merchant_price and price_label are the price of min_face_value only. merchant_price_min and merchant_price_max give the price of the lowest and highest amount. The price of an amount in between is not a simple multiple of either. Before you set your selling price or order, get a quote with the exact amount and send that quote's merchant_price as expected_unit_price.

Some range SKUs also have an amount_step. When it is not null, amount must be a multiple of it. For example, with "amount_step": "1" you can send 25 but not 25.50. A quote or order with an amount that does not fit the step returns HTTP 400 and charges nothing.

#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", "type": "text", "required": true},
  {"key": "server", "label": "Server", "type": "text", "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. type is a hint for your form field, such as text.
  • 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.25",
  "quantity": 2,
  "total_price": "18.50",
  "min_quantity": 1,
  "max_quantity": 100,
  "availability": "available"
}
  • A quote does not hold the price. Prices can change at any time.
  • Quote once, when your customer checks out, with a short timeout such as 5 seconds. If the quote times out, keep showing your synced price and try again; never show an error page because a quote was slow.
  • To protect yourself, send merchant_price as expected_unit_price when you order. Send the price from a quote, not a price you calculated yourself: it must match exactly, to the cent. If the price changed, CardV rejects the order and charges nothing.
  • A quantity or amount out of range, or an amount that is not a multiple of amount_step, 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. Like every call, it must be signed.

JSON
{
  "external_order_id": "SHOP-20260929-10001",
  "items": [
    {"sku_id": "S000456", "quantity": 2, "expected_unit_price": "9.25"},
    {"sku_id": "S000789", "amount": "25.00", "expected_unit_price": "23.75"},
    {
      "sku_id": "S000900",
      "expected_unit_price": "4.90",
      "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. Must fit min_face_value, max_face_value and amount_step.
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.20",
    "...": "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.25 to 9.41 USD; refresh quote and confirm again."
}

Your account has limits on one order, on daily spend and on daily order count. GET/api/v1/account shows them in purchase_limits, with what you have used today. 0 means no limit. Daily limits reset at 00:00 UTC.

#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.50",
  "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.25",
      "total_price": "18.50",
      "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",
  "card_number": "X1234",
  "pin_code": "9876",
  "redeem_url": "",
  "expiry_date": "2027-09-29",
  "instructions": "Redeem at ..."
}
FieldWhat it is
card_numberThe main code: card number, voucher code or PIN.
pin_codeThe second code, such as a PIN or security code. Empty when there is none.
redeem_urlRedemption link. For link deliveries the link itself is the code.
expiry_dateLast day the code can be used, as YYYY-MM-DD. "" means the supplier gave no expiry date. It does not mean the code has expired.
  • Give your customer every field that is not empty: card_number, pin_code, redeem_url, expiry_date and instructions.
  • delivery_type says what you got: code, card_pin, link, code_link or qr.
  • Keep redeem_url secret, like a code.
  • Never delete, hide or void a code because expiry_date is empty. Show an expiry date to your customer only when it is not empty.
  • 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.
  • 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.