개발자/연동
상품과 주문
이 문서는 구매 과정 전체를 순서대로 설명합니다. 잔액 확인, 상품 찾기, 가격 조회, 주문, 코드 받기 순입니다.
#계정과 잔액
#계정
GET/api/v1/account는 회사 정보와 API 사용 가능 여부를 보여 줍니다.
{
"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는 지금 쓸 수 있는 금액을 보여 줍니다.
{
"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 목록을 반환합니다. 예:
GET /api/v1/skus?search=steam®ion=US&limit=50&offset=0필터(모두 선택 사항):
| 필터 | 예시 | 검색 대상 |
|---|---|---|
search | steam | SKU ID, 상품명, 브랜드 |
brand | Steam | 브랜드명(대소문자 구분 없음) |
region | US | 국가 코드 또는 국가명 |
vertical | gift_card | 상품군 |
product_type | pin_code | 제공 방식 |
페이지 나누기: limit(기본 100, 최대 500)과 offset을 보냅니다.
offset을 늘려 가며 offset이 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}는 SKU 하나를 같은 필드로 반환합니다.
자주 쓰는 필드:
| 필드 | 의미 |
|---|---|
sku_id | 가격 조회와 주문에 쓰는 ID입니다. |
merchant_price | 귀사가 내는 개당 가격입니다(settlement_currency 기준). |
availability | available 또는 unavailable. available인 SKU만 주문하세요. |
denomination_type | fixed 또는 range. 고정 금액과 범위 금액을 참고하세요. |
face_currency | 카드에 표시된 통화입니다. 지갑 통화와 다를 수 있습니다. |
min_quantity, max_quantity | 주문 항목 하나에 담을 수 있는 수량 범위입니다. |
product_type | pin_code(코드를 받음) 또는 direct_charge(CardV가 계정에 직접 충전). |
required_input_schema | 직접 충전 시 보내야 하는 정보입니다. |
brand_logo_url, image_url | CardV가 호스팅하는 이미지, 또는 "". |
description, redemption_instructions, terms | 고객에게 보여 줄 수 있는 안내 문구입니다. |
참고:
- 활성 상태이면서 귀사 계정에 공개된 SKU만 보입니다. 그 밖의 SKU는 404를 반환합니다.
- SKU 목록은 5~15분마다 동기화하세요. 주문 직전에는 항상 가격을 다시 조회하세요.
filter_options에는 필터로 쓸 수 있는 브랜드, 지역, 상품군이 나옵니다.
#고정 금액과 범위 금액
대부분의 SKU는 10 USD처럼 액면가가 고정되어 있습니다. 일부 SKU는 5~500 USD처럼 범위 안에서 고객이 금액을 정합니다.
| 유형 | 가격 조회 시 | 주문 시 |
|---|---|---|
fixed | quantity를 보냄 | amount는 보내지 않음 |
range | quantity와 amount를 보냄 | amount를 보냄 |
범위 금액 SKU에서 amount는 min_face_value 이상 max_face_value 이하여야 합니다.
단위는 face_currency입니다.
#직접 충전
일부 상품은 게임 계정처럼 고객의 계정에 바로 충전됩니다. 이런 상품은 CardV가 충전할 계정 정보가 필요하며, 필요한 항목은 SKU에 나와 있습니다.
"required_input_schema": [
{"key": "player_id", "label": "Player ID", "required": true},
{"key": "server", "label": "Server", "required": false}
]주문 항목의 inputs에 각 key별로 값을 넣어 보냅니다.
"inputs": {"player_id": "123456789", "server": "EU"}"required": false라고 표시되지 않은 항목은 모두 필수입니다.- 필수 값이 빠지면
items오류와 함께 주문이 거절됩니다. - 이 값은 고객의 개인정보입니다. 안전하게 보호하세요(보안 참고).
#가격 조회
가격 조회를 하면 원하는 수량의 현재 가격을 알 수 있습니다.
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"
}- 조회한 가격이 보장되지는 않습니다. 가격은 언제든 바뀔 수 있습니다.
- 주문할 때 조회한
merchant_price를expected_unit_price로 함께 보내세요. 그 사이 가격이 바뀌었다면 CardV가 주문을 거절하고 결제하지 않습니다. - 수량이나 금액이 허용 범위를 벗어나면 HTTP 400과 함께
quantity또는amount오류가 반환됩니다.
#주문하기
POST/api/v1/orders로 SKU를 하나 이상 구매하며, 대금은 지갑에서 결제됩니다.
이 요청에는 서명이 필요합니다.
{
"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입니다.
{
"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 | 자체 주문번호가 이미 다른 주문에 쓰임 | 안전한 재시도를 참고하세요 |
가격이 바뀐 경우의 예:
{
"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를 보낸다면 처음 주문할 때의 가격과 같아야 합니다.
중복 주문 여부는 잔액과 가격을 확인하기 전에 판단합니다. 따라서 그 사이 가격이 바뀌었더라도 항상 처음 주문이 반환됩니다.
#안전한 재시도 절차
결과를 확실히 모르겠다면 같은 주문을 그대로 다시 보내면 됩니다.
주문번호 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}는 주문 정보와 상태, 코드를 반환합니다.
{
"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가 반환됩니다.
#주문 상태와 코드
#주문 상태
accepted ──► processing ──► succeeded
│
├──► partially_succeeded (일부 항목만 발급됨)
│
└──► failed ──► refunded (지갑으로 환불됨)| 상태 | 완료 여부 | 할 일 |
|---|---|---|
accepted | 아니요 | 기다립니다. 지갑 결제는 끝났고 발급은 아직 시작되지 않았습니다. |
processing | 아니요 | 기다립니다. 같은 주문을 다시 넣지 마세요. |
succeeded | 예 | 코드를 받아 고객에게 전달합니다. |
partially_succeeded | 예 | 발급된 코드만 전달합니다. 나머지는 나중에 환불됩니다. |
failed | 아직 아님 | refunded가 될 때까지 기다립니다. 실패했다고 바로 환불된 것은 아닙니다. |
refunded | 예 | 대금이 지갑으로 돌아왔습니다. |
Webhook을 쓰지 않는다면 다음 간격으로 조회하세요. 5초 후, 10초, 30초, 60초 후, 그다음부터는 5분마다. 요청 한도를 넘지 않도록 주의하세요. 대부분의 주문은 몇 초 안에 끝나지만, 수동 확인이 필요한 주문은 몇 시간 걸릴 수 있습니다.
#코드
발급된 상품 1개는 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
}- 고객에게는
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]로 보내 주세요.