개발자/연동

상품과 주문

이 문서는 구매 과정 전체를 순서대로 설명합니다. 잔액 확인, 상품 찾기, 가격 조회, 주문, 코드 받기 순입니다.

관련 문서: 인증 · 공통 규칙 · Webhook

#계정과 잔액

#계정

GET/api/v1/account는 회사 정보와 API 사용 가능 여부를 보여 줍니다.

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는 지갑 통화입니다. 결제 금액은 모두 이 통화 기준입니다.
  • api_access_enabled는 CardV가 사업자 심사를 승인하면 true가 됩니다.

#잔액

GET/api/v1/balance는 지금 쓸 수 있는 금액을 보여 줍니다.

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는 지금 바로 쓸 수 있는 금액입니다. balance에서 reserved_amount를 뺀 값입니다.
  • 주문 금액이 available_balance보다 크면 주문이 거절되고, 결제도 되지 않습니다.
  • low_balance_threshold는 잔액 부족 알림 이메일을 보내는 기준 금액입니다. 포털에서 설정합니다.
  • 지갑 충전은 포털에서 합니다.

#상품(SKU)

SKU는 구매할 수 있는 상품 하나를 말합니다. 예: "Steam Wallet 10 USD" ID는 S000456 형태이며, 가격 조회와 주문은 SKU 단위로 합니다.

GET/api/v1/skus는 구매할 수 있는 SKU 목록을 반환합니다. 예:

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

필터(모두 선택 사항):

필터예시검색 대상
searchsteamSKU ID, 상품명, 브랜드
brandSteam브랜드명(대소문자 구분 없음)
regionUS국가 코드 또는 국가명
verticalgift_card상품군
product_typepin_code제공 방식

페이지 나누기: limit(기본 100, 최대 500)과 offset을 보냅니다. offset을 늘려 가며 offset이 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}는 SKU 하나를 같은 필드로 반환합니다.

자주 쓰는 필드:

필드의미
sku_id가격 조회와 주문에 쓰는 ID입니다.
merchant_price귀사가 내는 개당 가격입니다(settlement_currency 기준).
availabilityavailable 또는 unavailable. available인 SKU만 주문하세요.
denomination_typefixed 또는 range. 고정 금액과 범위 금액을 참고하세요.
face_currency카드에 표시된 통화입니다. 지갑 통화와 다를 수 있습니다.
min_quantity, max_quantity주문 항목 하나에 담을 수 있는 수량 범위입니다.
product_typepin_code(코드를 받음) 또는 direct_charge(CardV가 계정에 직접 충전).
required_input_schema직접 충전 시 보내야 하는 정보입니다.
brand_logo_url, image_urlCardV가 호스팅하는 이미지, 또는 "".
description, redemption_instructions, terms고객에게 보여 줄 수 있는 안내 문구입니다.

참고:

  • 활성 상태이면서 귀사 계정에 공개된 SKU만 보입니다. 그 밖의 SKU는 404를 반환합니다.
  • SKU 목록은 5~15분마다 동기화하세요. 주문 직전에는 항상 가격을 다시 조회하세요.
  • filter_options에는 필터로 쓸 수 있는 브랜드, 지역, 상품군이 나옵니다.

#고정 금액과 범위 금액

대부분의 SKU는 10 USD처럼 액면가가 고정되어 있습니다. 일부 SKU는 5~500 USD처럼 범위 안에서 고객이 금액을 정합니다.

유형가격 조회 시주문 시
fixedquantity를 보냄amount는 보내지 않음
rangequantity와 amount를 보냄amount를 보냄

범위 금액 SKU에서 amount는 min_face_value 이상 max_face_value 이하여야 합니다. 단위는 face_currency입니다.

#직접 충전

일부 상품은 게임 계정처럼 고객의 계정에 바로 충전됩니다. 이런 상품은 CardV가 충전할 계정 정보가 필요하며, 필요한 항목은 SKU에 나와 있습니다.

JSON
"required_input_schema": [
  {"key": "player_id", "label": "Player ID", "required": true},
  {"key": "server", "label": "Server", "required": false}
]

주문 항목의 inputs에 각 key별로 값을 넣어 보냅니다.

JSON
"inputs": {"player_id": "123456789", "server": "EU"}
  • "required": false라고 표시되지 않은 항목은 모두 필수입니다.
  • 필수 값이 빠지면 items 오류와 함께 주문이 거절됩니다.
  • 이 값은 고객의 개인정보입니다. 안전하게 보호하세요(보안 참고).

#가격 조회

가격 조회를 하면 원하는 수량의 현재 가격을 알 수 있습니다.

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"
}
  • 조회한 가격이 보장되지는 않습니다. 가격은 언제든 바뀔 수 있습니다.
  • 주문할 때 조회한 merchant_price를 expected_unit_price로 함께 보내세요. 그 사이 가격이 바뀌었다면 CardV가 주문을 거절하고 결제하지 않습니다.
  • 수량이나 금액이 허용 범위를 벗어나면 HTTP 400과 함께 quantity 또는 amount 오류가 반환됩니다.

#주문하기

POST/api/v1/orders로 SKU를 하나 이상 구매하며, 대금은 지갑에서 결제됩니다. 이 요청에는 서명이 필요합니다.

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"}
    }
  ]
}
필드필수 여부의미
external_order_id필수자체 주문번호, 1~120자. 중복될 수 없습니다.
items필수주문 항목. 하나 이상 넣습니다.
items[].sku_id필수구매할 SKU입니다.
items[].quantity선택수량. 기본값은 1입니다.
items[].amount범위 금액 SKU만구매할 액면가입니다.
items[].expected_unit_price권장조회한 merchant_price. 항상 보내세요.
items[].inputs직접 충전 상품만직접 충전에 필요한 계정 정보입니다.

CardV가 주문을 접수하면 총액 전체를 지갑에서 한 번에 결제합니다. 그다음 상품 발급은 백그라운드에서 진행됩니다.

응답은 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"
  }
}

order.order_id를 저장하세요. 이 응답에는 코드가 들어 있지 않습니다. 코드는 나중에 따로 조회합니다(주문 조회 참고).

#주문 거절

거절된 주문은 HTTP 400을 반환하며, 결제되지 않습니다. 거절 이유는 오류 키로 알 수 있습니다.

키원인해결 방법
items가격 변경, 판매 중지된 SKU, 잘못된 금액, 필수 정보 누락가격을 다시 조회하고 고친 뒤 다시 보내세요
balance지갑 잔액 부족포털에서 지갑을 충전하세요
risk주문당 한도 또는 일일 한도 초과CardV에 문의하세요
external_order_id자체 주문번호가 이미 다른 주문에 쓰임안전한 재시도를 참고하세요

가격이 바뀐 경우의 예:

JSON
{
  "items": "SKU S000456 price changed from 9.2500 to 9.4100 USD; refresh quote and confirm again."
}

주문 1건 금액, 하루 결제 금액, 하루 주문 건수 한도는 계정 등급에 따라 정해집니다. 일일 한도는 매일 00:00(UTC)에 초기화됩니다. 귀사의 한도는 CardV에 문의하세요.

#안전한 재시도

자체 주문번호(external_order_id)가 있으면 같은 주문이 두 번 결제되는 일을 막을 수 있습니다. 같은 주문번호로 같은 주문을 다시 보내면 CardV는 다시 결제하지 않고, 이미 있는 주문을 그대로 돌려줍니다.

보낸 요청결과
새 주문번호HTTP 201. 새 주문이 생성되고 지갑에서 결제됩니다.
같은 주문번호, 같은 주문 내용HTTP 200과 "idempotent_replay": true. 기존 주문이 반환되며 결제되지 않습니다.
같은 주문번호, 다른 주문 내용external_order_id에 대한 HTTP 400. 아무 일도 일어나지 않습니다.

"같은 주문 내용"이란 항목의 순서, SKU, 수량, 금액, 입력값이 모두 같다는 뜻입니다. expected_unit_price를 보낸다면 처음 주문할 때의 가격과 같아야 합니다.

중복 주문 여부는 잔액과 가격을 확인하기 전에 판단합니다. 따라서 그 사이 가격이 바뀌었더라도 항상 처음 주문이 반환됩니다.

#안전한 재시도 절차

결과를 확실히 모르겠다면 같은 주문을 그대로 다시 보내면 됩니다.

Text
주문번호 R로 POST /orders 전송
 ├─ 201 또는 200 → order_id를 저장합니다. 끝.
 ├─ 400 items / balance / risk → 주문이 생성되지 않았습니다.
 │      원인을 해결하고 다시 보냅니다. R을 그대로 써도 됩니다.
 ├─ 400 external_order_id → R은 다른 주문에 쓰인 번호입니다. 멈추고 확인하세요.
 ├─ 403 서명 오류 → 다시 서명해서 같은 본문을 보냅니다.
 ├─ 429 → Retry-After만큼 기다린 뒤 다시 서명해서 같은 본문을 보냅니다.
 └─ 타임아웃, 5xx, 연결 끊김
        → 같은 R로 같은 본문을 다시 보냅니다.
          201이면 첫 요청이 도착하지 않은 것이고, 200이면 도착했던 것입니다.

규칙:

  • 응답을 받지 못했다고 새 주문번호를 만들면 안 됩니다. 첫 요청이 실제로 도착했다면, 새 번호로 보낸 주문 때문에 같은 상품을 두 번 사게 됩니다.
  • 다시 보낼 때마다 타임스탬프, nonce, 서명은 새로 만들고, 본문은 그대로 둡니다.

#주문 조회

GET/api/v1/orders/{order_id}는 주문 정보와 상태, 코드를 반환합니다.

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는 주문이 접수될 때 결제된 금액입니다.
  • items[].unit_price는 이 주문에 확정된 개당 가격입니다.
  • items[].deliveries에는 전체 코드가 들어 있습니다. 응답 전체를 비밀 정보로 다루세요.
  • invoice_url과 delivery_file_url은 인보이스와 코드 CSV 파일의 경로입니다. 둘 다 포털 전용이므로 API 키로 호출하면 HTTP 403이 반환됩니다.
  • 이 밖에 id(예전 번호이니 쓰지 마세요), events(화면 표시용 이력), 항목별 발급 진행 상황도 반환됩니다. 무시해도 됩니다.
  • 없는 주문 ID를 조회하면 HTTP 404가 반환됩니다.

#주문 상태와 코드

#주문 상태

Text
accepted ──► processing ──► succeeded
                  │
                  ├──► partially_succeeded   (일부 항목만 발급됨)
                  │
                  └──► failed ──► refunded   (지갑으로 환불됨)
상태완료 여부할 일
accepted아니요기다립니다. 지갑 결제는 끝났고 발급은 아직 시작되지 않았습니다.
processing아니요기다립니다. 같은 주문을 다시 넣지 마세요.
succeeded예코드를 받아 고객에게 전달합니다.
partially_succeeded예발급된 코드만 전달합니다. 나머지는 나중에 환불됩니다.
failed아직 아님refunded가 될 때까지 기다립니다. 실패했다고 바로 환불된 것은 아닙니다.
refunded예대금이 지갑으로 돌아왔습니다.

Webhook을 쓰지 않는다면 다음 간격으로 조회하세요. 5초 후, 10초, 30초, 60초 후, 그다음부터는 5분마다. 요청 한도를 넘지 않도록 주의하세요. 대부분의 주문은 몇 초 안에 끝나지만, 수동 확인이 필요한 주문은 몇 시간 걸릴 수 있습니다.

#코드

발급된 상품 1개는 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
}
  • 고객에게는 display_fields를 보여 주세요. 각 항목에 label과 value가 있습니다. redeem_url, expiry_date, instructions도 값이 있으면 함께 보여 주세요.
  • kind는 코드와 PIN이면 secret, 시리얼 번호 같은 참고 정보면 reference입니다.
  • delivery_type은 받은 코드의 형태입니다. code, card_pin, link, code_link, qr 중 하나입니다. 새 형태가 추가될 수 있으니 화면은 항상 display_fields를 기준으로 만드세요.
  • link 형태는 redeem_url 자체가 코드입니다. 비밀로 관리하세요.
  • status가 voided인 코드는 절대 고객에게 전달하지 마세요.
  • 직접 충전 상품은 보통 발급 코드가 없습니다. succeeded는 계정 충전이 끝났다는 뜻입니다.
  • card_number, pin_code 같은 값은 별도 필드에도 복사되어 있지만, 비어 있을 수 있습니다.
  • Webhook에는 코드가 절대 들어 있지 않습니다. Webhook을 받은 뒤 주문을 조회하세요.

연동 관련 문의는 Merchant ID와 주문 ID 또는 요청 ID를 포함해 [email protected]로 보내 주세요.