본문으로 건너뛰기
VBANK

S-19 · v1

결제 API

VBank 결제 창으로 회원을 보내고, 결과를 웹훅이나 조회 API로 받아요. 서명·로그인·결제는 모두 www.bankofv.co.kr에서 일어나요. 파트너 페이지는 이동(redirect) 또는 팝업으로만 열 수 있고, iframe은 막혀 있어요.

시작하기

  1. VBank 운영자에게 허용 출처(https), 웹훅 URL(https, 443, 공인 IP), 팔 컬렉션 목록 등록을 요청해요.
  2. 운영자가 API 키(vb_test_… = Amoy 테스트, vb_live_… = Polygon 실서비스)와 모드별 웹훅 비밀값(whsec_…)을 발급해요. 원문은 발급할 때 한 번만 보여요.
  3. 키는 서버에만 두세요. 브라우저나 앱에 넣지 않아요.

테스트 키는 테스트 환경에서만, 실서비스 키는 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. 결제 창 위에 "테스트"가 표시돼요.
  • 테스트 세션과 실서비스 세션은 서로 보이지 않아요. 웹훅 비밀값도 모드별로 따로예요.