S-19 · v1
결제 API
VBank 결제 창으로 회원을 보내고, 결과를 웹훅이나 조회 API로 받아요. 서명·로그인·결제는 모두 www.bankofv.co.kr에서 일어나요. 파트너 페이지는 이동(redirect) 또는 팝업으로만 열 수 있고, iframe은 막혀 있어요.
시작하기
- VBank 운영자에게 허용 출처(https), 웹훅 URL(https, 443, 공인 IP), 팔 컬렉션 목록 등록을 요청해요.
- 운영자가 API 키(vb_test_… = Amoy 테스트, vb_live_… = Polygon 실서비스)와 모드별 웹훅 비밀값(whsec_…)을 발급해요. 원문은 발급할 때 한 번만 보여요.
- 키는 서버에만 두세요. 브라우저나 앱에 넣지 않아요.
테스트 키는 테스트 환경에서만, 실서비스 키는 https://www.bankofv.co.kr 에서만 통해요. 반대로 보내면 403 MODE_NOT_SERVED가 와요.
공통
- 기본 주소: https://www.bankofv.co.kr/api/v1
- 인증: Authorization: Bearer vb_live_…
- 요청·응답은 JSON이고 응답에는 Cache-Control: no-store가 붙어요.
- 금액은 정수 문자열, USDT 최소 단위(6자리). "125000000" = 125 USDT.
- 주소·해시는 소문자 0x…, 시각은 UTC ISO 8601(초 단위).
- 레이트 리밋: 키마다 분당 120회, IP마다 분당 600회. 넘으면 429 RATE_LIMITED.
- 오류: {"error": "<코드>", "message": "<설명>"}
오류 코드
- 400 INVALID_REQUEST
- 본문 형식 오류(message에 필드)
- 400 INVALID_JSON / INVALID_IDEMPOTENCY_KEY
- JSON이 아님 / 키 형식 오류
- 400 INVALID_URL / URL_NOT_ALLOWED
- success/cancel URL이 https가 아니거나 등록한 출처와 다름
- 401 INVALID_API_KEY
- 키 없음·틀림·폐기됨
- 403 MODE_NOT_SERVED
- 이 환경이 받지 않는 모드의 키
- 403 PARTNER_SUSPENDED
- 파트너 정지. 새 세션만 막아요
- 403 COLLECTION_NOT_ALLOWED
- 허용 컬렉션 밖의 상품
- 404 ITEM_NOT_FOUND / SESSION_NOT_FOUND
- 상품·세션 없음
- 409 ITEM_UNAVAILABLE / SOLD_OUT
- 판매 시작 전·종료, 주문 만료 / 남은 수량 부족
- 409 IDEMPOTENCY_KEY_REUSED
- 같은 Idempotency-Key를 다른 본문에 사용
- 409 SESSION_PROCESSING / SESSION_FINAL
- 결제 진행 중이거나 이미 끝나 취소할 수 없음
- 413 TOO_LARGE
- 본문 16KB 초과
- 422 QUANTITY_LIMIT
- 지갑당 구매 한도 초과
- 429 RATE_LIMITED
- 레이트 리밋
세션 만들기
Idempotency-Key 헤더(선택): 같은 키와 같은 본문이면 처음 결과를 돌려줘요. 본문이 다르면 409. 같은 partner_order_id도 기존 세션을 돌려줘요(새로 만들면 201, 기존이면 200).
POST /checkout/sessions
{
"kind": "LAUNCHPAD",
"event_id": "12",
"quantity": 1,
"partner_order_id": "ORD-2026-000123",
"success_url": "https://partner.example/orders/123/done",
"cancel_url": "https://partner.example/orders/123",
"locale": "ko",
"metadata": { "campaign": "fall" }
}필드
- kind
- LAUNCHPAD(런치패드 이벤트) 또는 MARKET(마켓 판매 주문)
- event_id, quantity
- LAUNCHPAD. event_id는 정수 문자열, quantity는 1 이상(지갑당 한도 이하)
- order_hash
- MARKET. 살아 있는 판매 주문의 해시(0x + 64자)
- partner_order_id
- 파트너 안에서 유일. A–Z a–z 0–9 . _ : -, 1–64자
- success_url, cancel_url
- https, 등록한 출처와 정확히 일치(경로는 자유)
- locale
- ko(기본) 또는 en
- metadata
- 선택. 문자열 값만, 키 20개까지. 개인정보는 넣지 않아요
{
"id": "cs_5f0c…",
"url": "https://www.bankofv.co.kr/checkout/cs_5f0c…",
"status": "OPEN",
"currency": "USDT",
"total_minor": "125000000",
"expires_at": "2026-11-01T03:30:00Z",
"mode": "live"
}- 알 수 없는 필드가 있으면 400 INVALID_REQUEST.
- 가격은 만들 때 체인 또는 주문 서명에서 읽어 고정해요. total_minor는 회원이 낼 수 있는 최대 금액이에요. 등급 할인이 있으면 실제 결제액(paid_minor)은 더 작을 수 있어요.
- 세션은 30분 유효해요. MARKET은 주문 만료가 더 빠르면 그때까지예요.
- 회원을 url로 보내요(이동 또는 팝업).
세션 조회
만들기 응답의 필드에 아래가 더 붙어요.
GET /checkout/sessions/{id}
{
…,
"partner_order_id": "ORD-2026-000123",
"kind": "LAUNCHPAD", "event_id": "12", "quantity": 1, "order_hash": null,
"collection": "0x…", "token_ids": ["101"],
"paid_minor": "112500000", "failure_reason": null,
"tx_hash": "0x…", "recipient_wallet": "0x…", "chain_id": 137,
"paid_at": "2026-11-01T03:12:44Z", "created_at": "2026-11-01T03:00:00Z",
"locale": "ko", "metadata": { "campaign": "fall" }
}status
- OPEN
- 결제 대기
- PROCESSING
- 서명을 받아 전송함, 체인 확정 대기
- PAID
- 체인 이벤트로 확정(최종)
- FAILED
- 실패(최종). failure_reason: PRICE_CHANGED, ITEM_UNAVAILABLE, SOLD_OUT, LIMIT, TX_REVERTED
- EXPIRED
- 시간 초과 또는 파트너 취소
회원 이메일·이름은 어떤 응답에도 없어요. recipient_wallet은 체인에 공개된 받는 지갑 주소예요.
세션 취소
POST /checkout/sessions/{id}/expire
결제 전(OPEN, 진행 중인 전송 없음)에만 돼요. 이미 EXPIRED이면 그대로 돌려줘요. checkout.session.expired 웹훅이 가요.
취소와 거의 같은 순간에 회원의 거래가 이미 전송됐다면, 그 거래가 확정될 때 세션이 EXPIRED에서 PAID로 바뀌고 completed 웹훅이 뒤따라요. 주문 처리는 항상 마지막 상태를 기준으로 하세요.
상품 목록
GET /catalog/items?collection=0x…
허용 컬렉션의 판매 중인 상품이에요. collection을 빼면 허용 컬렉션 전체, 허용 밖이면 403.
표시용이에요(인덱서 기준이라 몇 초 늦을 수 있어요). 가격은 세션을 만들 때 체인에서 다시 확인해요.
웹훅
- checkout.session.completed
- PAID
- checkout.session.failed
- FAILED
- checkout.session.expired
- EXPIRED
- POST <웹훅 URL>, Content-Type: application/json.
- VBank-Signature: t=<unix초>,v1=<hex> — HMAC-SHA256(웹훅 비밀값, "<t>.<원문 본문>").
- VBank-Event-Id: evt_… — 이벤트마다 고유하고 재전송에도 같아요. 중복을 거르는 데 써요.
- data.total_minor는 실제 결제액(paid_minor)이에요. failed에는 data.failure_reason이 더 붙어요.
- 10초 안에 2xx로 답하세요. 3xx는 따라가지 않고 실패로 봐요.
- 실패하면 1분, 5분, 30분, 2시간, 6시간, 24시간 뒤에 다시 보내요(최대 7번). 그래도 실패하면 운영자가 확인해요.
- 최소 한 번(at least once) 보내고, 순서는 보장하지 않아요. 처리 전에 조회 API로 현재 상태를 확인해도 돼요.
{
"id": "evt_…",
"type": "checkout.session.completed",
"created_at": "2026-11-01T03:12:44Z",
"mode": "live",
"data": {
"session_id": "cs_…",
"partner_order_id": "ORD-2026-000123",
"kind": "LAUNCHPAD",
"collection": "0x…",
"token_ids": ["101"],
"total_minor": "112500000",
"currency": "USDT",
"recipient_wallet": "0x…",
"tx_hash": "0x…",
"chain_id": 137
}
}서명 검증 (Node.js)
받은 원문 그대로(파싱 전 문자열)로 검증하고, 5분보다 오래된 t는 버리세요.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyVBankWebhook(secret, rawBody, header, nowSec = Math.floor(Date.now() / 1000)) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(nowSec - t) > 300) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1 ?? "", "hex");
return given.length === expected.length && timingSafeEqual(given, expected);
}결제 확정 규칙
- success_url로 돌아온 것은 결제 증거가 아니에요. 주문은 웹훅 또는 조회 API의 PAID로만 확정하세요.
- 인덱서가 본 체인 이벤트의 거래 해시가 이 세션으로 보낸 거래와 같고 상품·수량·금액이 맞을 때만 PAID가 돼요.
- 그 블록 위로 블록이 몇 개(운영 설정) 쌓인 뒤에 확정해요. Polygon에서는 대개 10–15초 걸려요.
- 맞지 않으면 확정하지 않고 운영자가 확인해요. 15분이 지나도 맞는 이벤트가 없으면 결제가 아닌 것으로 봐요.
테스트 모드
- vb_test_ 키 = Polygon Amoy(80002) + TestUSDT0. 결제 창 위에 "테스트"가 표시돼요.
- 테스트 세션과 실서비스 세션은 서로 보이지 않아요. 웹훅 비밀값도 모드별로 따로예요.