이 페이지의 주문서 SDK 흐름과 함께 서버에서 청구서를 직접 만들거나 조회할 때는 청구서 API를 참고해요. POST /v1/invoices는 Basic 인증과 공개 client_key 호출의 응답 형태가 달라요.
이 페이지의 브라우저 체크아웃 호출과 서버 REST 주문 준비는 입력 계약이 달라요. 서버 BFF에서는 서버 주문 준비의 products[].option.product_option_id, 상품 선결제 금액, URL 응답 계약을 사용해요.
회원이 다른 도메인의 Bootpay 주문서로 이동할 때는 서버 prepare 요청에 고객 Bootpay-User-JWT 헤더와 response_type: 'url', use_auto_login: true를 함께 전달해요. API가 기존 /b/{client_key} 세션 경유 URL을 반환하면 브라우저는 그 URL로 이동해요. 가맹점이 암호화나 경유 URL 생성을 구현하지 않아요. 자동 로그인 계약과 배포 확인을 참고해요.
범용 /b는 기존 청구서 전용 /i/.../session과 달라요. 일반 주문번호로 청구서 경로를 조합하지 않고, __s가 포함된 API 응답 URL도 재작성하지 않아요. "세션 만료" 문구가 보이더라도 실제 오류가 ORDER_NOT_PAYMENT_READY이면 주문 상태를 확인해요. 경로·식별자 구분과 만료 후 재시도를 참고해요.
상품 판매 페이지에 결제를 붙일 때, 구독 요금제 주문서를 만들 때예요.
내가 할 일: 관리자(상품 등록) + 프론트엔드(주문서 SDK) + 백엔드(주문 검증·웹훅)
Commerce SDK를 사용하여 구독/요금제 결제를 구현해요. 주문서를 통해 상품 선택부터 결제까지 한 번에 처리할 수 있어요.
체크아웃 빠른 매뉴얼 — JavaScript 최소 코드로 5분 안에 연동해요. 이 페이지는 전체 파라미터와 할인/프로모션을 다루는 Full Reference예요.
내가 하는 것
- 관리자 상품 관리에서 상품 등록 (또는 API로 등록)
- 서버에서 주문 완료 후 검증
- 웹훅으로 주문 상태 동기화
Bootpay가 알아서 하는 것
- 주문서 UI 호스팅, 결제 처리
- 주문 생성·저장, 고객 데이터 관리
- 웹훅 알림, 취소/환불 처리
연동 흐름
상품 등록 (관리자)
Bootpay 관리자 상품 대시보드에서 상품을 등록해요. 상품명, 가격, 구독 설정 등을 입력하면 product_id가 발급돼요.
API로 상품을 등록할 수도 있어요. 상품 등록 가이드를 참고해요.
주문서 요청 (개발자 · 프론트엔드)
// npm install @bootpay/bp-commerce-sdk
import BootpayCommerce from '@bootpay/bp-commerce-sdk'
const CLIENT_KEY = 'your-client-key'
async function selectPlan(planKey) {
// 서버에 주문 생성 요청 (위변조 방지)
const orderRes = await fetch('/api/orders/plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan_key: planKey })
})
const orderData = await orderRes.json()
// Commerce 주문서 요청
const response = await BootpayCommerce.requestCheckout({
client_key: CLIENT_KEY,
request_id: orderData.order_id,
name: orderData.order_name,
price: orderData.price,
redirect_url: window.location.origin + '/result',
user: {
membership_type: 'guest',
user_id: 'user_1234',
name: '홍길동',
phone: '01012345678',
email: 'user@example.com'
},
products: [
{
product_id: 'product_id_here',
quantity: 1
}
],
metadata: {
order_id: orderData.order_id,
plan_key: planKey
},
extra: {
open_type: 'redirect',
separately_confirmed: false,
create_order_immediately: true
}
})
}javascript<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>요금제 선택</title>
<script src="https://js.bootpay.co.kr/commerce/bp-commerce-sdk-1.0.7.min.js"></script>
</head>
<body>
<script>
const CLIENT_KEY = 'your-client-key'
async function selectPlan(planKey) {
try {
// 서버에 주문 생성 요청 (위변조 방지)
const orderRes = await fetch('/api/orders/plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plan_key: planKey })
})
const orderData = await orderRes.json()
// Commerce 주문서 요청
const response = await BootpayCommerce.requestCheckout({
client_key: CLIENT_KEY,
request_id: orderData.order_id,
name: orderData.order_name,
price: orderData.price,
redirect_url: window.location.origin + '/result.html',
user: {
membership_type: 'guest',
user_id: 'user_1234',
name: '홍길동',
phone: '01012345678',
email: 'user@example.com'
},
products: [
{
product_id: 'product_id_here',
quantity: 1
}
],
metadata: {
order_id: orderData.order_id,
plan_key: planKey
},
extra: {
open_type: 'redirect',
separately_confirmed: false,
create_order_immediately: true
}
})
} catch (error) {
console.error('주문서 요청 오류:', error)
}
}
</script>
</body>
</html>html// web/index.html <head>에 Commerce JS SDK 추가 필수:
// <script src="https://js.bootpay.co.kr/commerce/bp-commerce-sdk-1.0.7.min.js"></script>
import 'dart:js_interop';
@JS('BootpayCommerce.requestCheckout')
external JSPromise<JSAny> _requestCheckout(JSAny options);
Future<void> selectPlan(String planKey) async {
// 서버에 주문 생성 요청 (위변조 방지)
final orderData = await api.createOrder(planKey);
final options = {
'client_key': 'your-client-key',
'request_id': orderData['order_id'],
'name': orderData['order_name'],
'price': orderData['price'],
'redirect_url': '${Uri.base.origin}/result',
'user': {
'membership_type': 'guest',
'user_id': 'user_1234',
'name': '홍길동',
'phone': '01012345678',
'email': 'user@example.com',
},
'products': [
{
'product_id': 'product_id_here',
'quantity': 1,
}
],
'metadata': {
'order_id': orderData['order_id'],
'plan_key': planKey,
},
'extra': {
'open_type': 'redirect',
'separately_confirmed': false,
'create_order_immediately': true,
},
}.jsify();
await _requestCheckout(options).toDart;
}dart구독 상품이 들어 있는데 products 가 두 줄 이상이면 INVOICE_SUBSCRIPTION_REQUEST_ONLY_ONE 으로 거절돼요. 같은 상품을 두 줄로 나눠 넣어도 마찬가지예요 — 한 줄에 quantity 로 수량을 조절해요. 일반 주문에는 이 제약이 없어요.
Commerce 주문서는 웹 리디렉트 방식이에요. 모바일 앱에서는 WebView로 주문서 페이지를 열거나, 결제 요청에서 일반 결제 SDK를 사용해요.
요청 파라미터
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
client_key |
String | 필수 | Commerce 클라이언트 키 |
request_id |
String | 필수 | 가맹점 고유 식별자. 웹훅·주문 응답에 그대로 실려 와요 |
name |
String | 필수 | 주문명 (예: "Professional 플랜") |
price |
Integer | 선택 | 서버에서 계산한 결제 금액. 생략하면 상품 기준으로 계산해요 |
tax_free_price |
Integer | 선택 | 비과세 금액 |
delivery_price |
Integer | 선택 | 배송비 |
memo |
String | 선택 | 내부 메모 |
redirect_url |
String | 선택 | 결제 완료 후 이동 URL. open_type: 'redirect' 면 사실상 필수예요 |
expired_at |
String | 선택 | 주문서 만료 시각 (ISO 8601) |
usage_api_url |
String | 선택 | 사용량 기반 구독의 사용량 조회 URL |
use_auto_login |
Boolean | 선택 | 링크 진입 시 자동 로그인 여부 |
products |
Array | 선택 | 주문 상품 배열 |
└─ product_id |
String | 선택 | 상품 ID |
└─ product_option_id |
String | 선택 | 상품 옵션 ID. 옵션이 있는 상품이면 필수(INVOICE_PRODUCT_OPTION_ID_REQUIRED) |
└─ quantity |
Integer | 선택 | 수량 (1 이상) |
└─ duration |
Integer | 선택 | 구독 회차 수. 구독 기간이 설정된 구독 상품이면 필수 — 상품에 등록된 회차 중 하나여야 해요(INVOICE_PRODUCT_SUBSCRIPTION_DURATION_NOT_FOUND). 수시결제·사용량 구독은 생략해요 |
user |
Object | 필수 | 구매자 정보. 없으면 INVOICE_TARGET_NOT_FOUND |
└─ membership_type |
String | 선택 | 회원 유형 (member 기본 / guest) |
└─ user_id |
String | 필수 | member 면 부트페이에 등록된 고객의 user_id(몰 설정에 따라 로그인 ID·이메일도 가능) — 없는 회원이면 INVOICE_TARGET_NOT_FOUND. guest 면 가맹점 쪽 식별자이고, 처음 보는 값이면 비회원 고객을 새로 만들어요 |
└─ corporate_type |
String | 선택 | member 일 때 개인·기업 구분 (individual 기본 / corporate) |
└─ name |
String | 선택 | 구매자 이름 |
└─ phone |
String | 선택 | 구매자 전화번호 |
└─ email |
String | 선택 | 구매자 이메일 |
metadata |
Object | 선택 | 가맹점 커스텀 데이터 |
extra |
Object | 선택 | 추가 옵션 |
└─ open_type |
String | 필수 | 주문서를 여는 방식 (popup / iframe / redirect) |
└─ separately_confirmed |
Boolean | 선택 | 분리 승인 모드. 켜면 결제창이 끝난 뒤 서버가 결제 승인을 불러야 결제가 확정돼요 |
└─ create_order_immediately |
Boolean | 선택 | 즉시 주문 생성 여부 |
결제 완료 처리 (개발자 · 프론트엔드)
결제 완료 후 redirect_url로 이동하며, URL 파라미터로 결과를 전달해요.
| 파라미터 | 설명 |
|---|---|
order_number |
Commerce 주문 고유 번호 |
event |
성공 시에만 와요. done(결제 완료) · confirm(분리 승인 대기 — 서버가 결제 승인 호출) |
request_id |
주문서 요청 때 넘긴 가맹점 식별자 |
metadata |
주문서 요청 때 넘긴 커스텀 데이터 (JSON 문자열) |
error_code · message |
실패·취소 시에만 와요 (event 없이). 결제창을 닫으면 error_code=ORDER_CLOSED 예요 |
프론트엔드 결과만으로 주문을 완료하면 안 돼요. 반드시 서버에서 주문 검증을 수행해요.
서버 검증 (개발자 · 서버)
리다이렉트 쿼리로 돌아온 order_number(예: 25071182085082524116)를 그대로 넣어요.
24자리 order_id 를 넣으면 ORDER_NOT_FOUND 가 돌아와요.
curl -X GET "https://api.bootapi.com/v1/orders/25071182085082524116" \
-H "Authorization: Basic {base64(client_key:secret_key)}"bashconst { BootpayCommerce } = require('@bootpay/backend-js')
const commerce = new BootpayCommerce({
client_key: 'your-commerce-client-key',
secret_key: 'your-commerce-secret-key',
mode: 'production'
})
app.post('/api/orders/verify', async (req, res) => {
const { order_number } = req.body
const order = await commerce.order.detail(order_number)
const draft = await getOrderDraft(order.request_id) // 주문서 요청 때 넘긴 request_id
// 금액·상태 검증 (위변조 방지)
if (order.price !== draft.price) {
return res.json({ success: false, message: '금액 불일치' })
}
if (order.status !== 'payment_completed') {
return res.json({ success: false, message: '결제 완료 주문이 아니에요' })
}
await markOrderPaid(order.order_number)
res.json({ success: true })
})javascriptfrom bootpay_backend import BootpayCommerce
commerce = BootpayCommerce(
client_key='your-commerce-client-key',
secret_key='your-commerce-secret-key',
mode='production'
)
@app.route('/api/orders/verify', methods=['POST'])
def verify_order():
order_number = request.get_json()['order_number']
order = commerce.order.detail(order_number)
draft = get_order_draft(order['request_id'])
if order['price'] != draft['price']:
return jsonify({'success': False, 'message': '금액 불일치'})
if order['status'] != 'payment_completed':
return jsonify({'success': False, 'message': '결제 완료 주문이 아니에요'})
mark_order_paid(order['order_number'])
return jsonify({'success': True})pythonuse Bootpay\ServerPhp\BootpayCommerceApi;
$commerce = new BootpayCommerceApi('your-commerce-client-key', 'your-commerce-secret-key');
$order = $commerce->order->detail($orderNumber);
$draft = getOrderDraft($order['request_id']);
if ($order['price'] !== $draft['price'] || $order['status'] !== 'payment_completed') {
echo json_encode(['success' => false]);
exit;
}
markOrderPaid($order['order_number']);
echo json_encode(['success' => true]);phpimport kr.co.bootpay.store.BootpayStore;
import kr.co.bootpay.store.model.request.TokenPayload;
import kr.co.bootpay.store.model.response.BootpayStoreResponse;
TokenPayload tp = new TokenPayload("your-commerce-client-key", "your-commerce-secret-key");
BootpayStore commerce = new BootpayStore(tp).withToken();
BootpayStoreResponse response = commerce.order.detail(orderNumber);
System.out.println(response);
// response 의 price · status 를 내부 주문 초안과 대조해요 (status == "payment_completed")javarequire 'bootpay'
commerce = BootpayStore::RestClient.new(client_key: 'your-commerce-client-key', secret_key: 'your-commerce-secret-key')
order = commerce.order_detail(order_number: order_number)
raise '금액 불일치' if order['price'] != draft_price
raise '결제 완료 주문이 아니에요' if order['status'] != 'payment_completed'rubyimport "github.com/bootpay/backend-go/v2"
commerce := bootpay.NewCommerceApi("your-commerce-client-key", "your-commerce-secret-key")
order, err := commerce.Order.Detail(orderNumber)
if err != nil {
log.Fatal(err)
}
if order.Price != draftPrice || order.Status != "payment_completed" {
json.NewEncoder(w).Encode(map[string]bool{"success": false})
return
}
json.NewEncoder(w).Encode(map[string]bool{"success": true})gousing Bootpay.Commerce;
var commerce = new BootpayCommerceApi("your-commerce-client-key", "your-commerce-secret-key");
var order = await commerce.Order.Detail(orderNumber);
if (order.Price != draftPrice || order.Status != "payment_completed")
{
return Ok(new { success = false });
}
return Ok(new { success = true });csharp카드사·PG 응답까지 확인하려면 응답의 result_data.receipt_id 로 결제 API 를 한 번 더 조회해요 —
결제 매뉴얼: 서버 검증에서 다뤄요.
응답
주문 조회 응답
{
"order_name": "구독상품 - 1회차",
"price": 2800,
"status": "payment_completed",
"receipt_status": "receipt_success",
"order_number": "25071182085082524116",
"purchased_at": "2025-07-11T02:52:09Z",
"created_at": "2025-07-11T02:52:09Z",
"result_data": {
"receipt_id": "68707c598bbe23d076af189c",
"pg": "라이트페이",
"method": "카드",
"status": 1,
"card_data": {
"card_company": "KB국민",
"card_no": "557042******1074"
}
},
"order_subscriptions": [
{
"order_subscription_id": "68707c58b0eacea5cd974ef7",
"status": "subscribing",
"current_duration": 3,
"total_subscription_duration": 4
}
]
}json| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
order_name |
String | 필수 | 주문명 |
price |
Integer | 필수 | 결제 금액 |
status |
String | 필수 | 주문 상태 (order_pending: 대기, payment_pending: 결제 진행 중, payment_completed: 결제 완료) |
receipt_status |
String | 필수 | 결제 영수증 상태 (receipt_success: 완료) |
order_number |
String | 필수 | 주문 고유번호 |
purchased_at |
String | 필수 | 결제 일시 (ISO 8601) |
created_at |
String | 필수 | 주문 생성 일시 |
result_data |
Object | 선택 | 결제 결과 상세 |
receipt_id |
String | 필수 | Bootpay 영수증 ID |
pg |
String | 필수 | PG사 이름 |
method |
String | 필수 | 결제 수단 |
status |
Integer | 필수 | 결제 상태 (1: 완료) |
card_data |
Object | 선택 | 카드 결제 시 카드 정보 |
card_company |
String | 필수 | 카드사 |
card_no |
String | 필수 | 마스킹된 카드번호 |
order_subscriptions |
Array | 선택 | 구독 계약 목록 (구독 주문일 때만 와요) |
order_subscription_id |
String | 필수 | 구독 ID |
status |
String | 필수 | 구독 상태 (subscribing: 구독 중) |
current_duration |
Integer | 필수 | 현재 구독 회차 |
total_subscription_duration |
Integer | 필수 | 총 구독 회차 |
주문 조회 응답과 웹훅에는 주문 문서 ID(order_id)가 없어요. 가맹점 DB 와는 order_number 와 request_id 로 맞춰요.
주문 상태 관리
주문서 결제 후 가맹점 DB에서 주문 상태를 관리해요.
| 시점 | 가맹점 DB 상태 | 비고 |
|---|---|---|
| 주문서 요청 전 | pending |
주문 예비 생성 (서버에서 가격 계산) |
| 서버 검증 성공 | paid |
bootpay_order_number 저장 |
| 배송 시작 | shipping |
가맹점이 직접 변경 |
| 배송 완료 | completed |
가맹점이 직접 변경 |
| 환불 웹훅 수신 | refunded |
order.cancelled 웹훅으로 확인 |
응답의 status 같은 코드값은 정수가 아니라 문자열(enum)로 내려와요. 전체 목록은 커머스 Enum에서 확인해요.
에러 코드
인증·권한 관련 에러는 에러 코드표를 참고해요.
| 코드 | 메시지 | 대처 방법 |
|---|---|---|
INVOICE_TARGET_NOT_FOUND |
주문서에 연결할 고객 정보가 없어요. | user.user_id 를 넣었는지, member 라면 부트페이에 등록된 고객인지 확인해요 |
INVOICE_REQUEST_ID_BLANK |
요청 ID가 필요해요. | request_id 를 넣어요 |
INVOICE_EXPIRED_DATE_INVALID |
만료일이 현재 시간보다 과거예요. | expired_at 을 미래 시각으로 넣어요 |
INVOICE_PRODUCT_NOT_FOUND |
존재하지 않는 상품이 포함되어 있어요. | product_id 가 이 프로젝트의 판매 중 상품인지 확인해요 |
INVOICE_ITEM_QTY_LT_ZERO |
상품 수량은 0보다 커야 해요. | quantity 를 1 이상으로 넣어요 |
INVOICE_PRODUCT_OPTION_ID_REQUIRED |
옵션이 필요한 상품이 포함되어 있어요. | product_option_id 를 넣어요 |
INVOICE_PRODUCT_OPTION_NOT_FOUND |
존재하지 않는 옵션이 포함되어 있어요. | product_option_id 를 확인해요 |
INVOICE_SUBSCRIPTION_REQUEST_ONLY_ONE |
구독 상품은 하나만 담을 수 있어요. | products 를 한 줄로 줄여요 |
INVOICE_PRODUCT_SUBSCRIPTION_DURATION_NOT_FOUND |
존재하지 않는 구독 기간이 포함되어 있어요. | duration 을 상품에 등록된 회차로 넣어요 |
INVOICE_NEED_USAGE_API_URL |
사용량 기반 구독 상품은 사용량 API URL이 필요해요. | 상품에 사용량 조회 URL 을 등록해요 |
주문서 화면에서 결제하는 도중에 나는 에러(재고 소진·배송지 누락 등)는 이 호출의 응답이 아니라 결제 완료 처리의 error_code 로 돌아와요.
할인/프로모션 적용
서버에서 청구서를 미리 생성하여 할인을 적용할 수 있어요.
// 서버에서 청구서 생성 (할인 적용)
const invoice = await commerce.invoice.create({
products: [{ product_id: 'product_123', quantity: 1 }],
price: finalPrice, // 할인 적용된 최종 금액
original_price: originalPrice,
user: { user_id: 'user_1234', name: '홍길동' },
metadata: { promotion_code: 'SUMMER2024' }
})
// 프론트엔드에서 invoice_id를 request_id로 전달
BootpayCommerce.requestCheckout({
request_id: invoice.invoice_id,
// ...
})javascript
