이 문서는 빌링키를 발급하고 원하는 시점의 결제를 예약하는 빠른 매뉴얼이에요. 예약 조회·취소·운영 주의사항과 API 세부 옵션은 예약결제 상세 문서에서 확인해요.
예약결제는 빌링키로 원하는 시점의 결제를 미리 등록하는 방식이에요. 고객이 결제창에 카유생비(카드번호, 유효기간, 생년월일, 비밀번호 앞 2자리)를 입력하면 PG/카드사가 카드 정보를 확인하고 빌링키를 발급해요. 가맹점 서버는 카드 정보를 직접 저장할 수 없기 때문에, 실제 카드 정보 대신 결제에 사용할 수 있는 암호화된 식별값을 저장해야 해요. 이 값이 빌링키예요.
빌링키 발급 방식은 두 가지예요. 보통은 프론트엔드에서 PG 결제창을 띄워 고객이 직접 입력하는 방식을 써요. 일부 PG에서는 백엔드 REST API로 카드 정보를 전달해 직접 발급하는 방식도 지원해요.
어느 방식이든 최종적으로 백엔드가 billing_key를 저장하고, 그 빌링키로 Bootpay API에 결제 예약을 등록해야 해요. 예약을 보관했다가 예약 시각에 결제를 실행하는 주체는 부트페이예요. 부트페이는 실행하는 그 순간에만 PG사로 빌링키 결제를 요청하고, 결과를 웹훅으로 알려줘요.
데모 영상
연동 흐름
1빌링키 발급
2예약 등록과 실행
빌링키 발급과 예약 등록은 별개의 단계예요. 먼저 둘 중 한 방식으로 빌링키를 발급·저장하고, 이후 서버에서 저장된 billing_key로 예약 결제 API를 호출해야 해요.
구현 순서
빌링키 발급 A — PG 결제창에서 발급 (프론트엔드)
권장 방식이에요. 프론트엔드에서 Bootpay SDK로 PG 결제창을 띄우고, 고객이 결제창에 카유생비를 입력하면 빌링키 발급이 진행돼요.
async function registerCard() {
const response = await Bootpay.requestSubscription({
client_key: '[ Client Key ]',
pg: '나이스페이',
method: '카드자동',
subscription_id: 'sub_' + Date.now(), // 고유 ID
price: 0, // 0원 = 빌링키만 발급 (결제 없음)
order_name: '카드 등록',
user: { username: '홍길동', phone: '01012345678' },
extra: { subscribe_test_payment: true }, // 100원 테스트 결제 후 자동 취소
})
if (response.event === 'done') {
await fetch('/api/billing-key/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ receipt_id: response.receipt_id }),
})
}
}javascriptlet payload = Payload()
payload.applicationId = BootpayConfig.applicationId
payload.pg = "나이스페이"
payload.method = "카드자동"
payload.subscriptionId = String(Date().timeIntervalSince1970)
payload.price = 0
payload.orderName = "카드 등록"
payload.extra = BootExtra()
payload.extra?.subscribeTestPayment = true
Bootpay.requestSubscription(viewController: self, payload: payload)
.onDone { data in
// data의 receipt_id를 백엔드 /api/billing-key/save로 전송
self.saveBillingKey(receiptId: data["receipt_id"] as? String ?? "")
}
.onError { _ in }
.onCancel { _ in }swiftval extra = BootExtra().setSubscribeTestPayment(true)
val payload = Payload()
.setApplicationId(BootpayConfig.applicationId)
.setOrderName("카드 등록")
.setPg("나이스페이")
.setMethod("카드자동")
.setSubscriptionId("sub_${System.currentTimeMillis()}")
.setPrice(0.0)
.setExtra(extra)
Bootpay.init(this).setPayload(payload)
.setEventListener(object : BootpayEventListener {
override fun onDone(data: String) {
// data의 receipt_id를 백엔드로 전송
saveBillingKey(data)
}
override fun onError(data: String) {}
override fun onCancel(data: String) {}
override fun onClose() { Bootpay.removePaymentWindow() }
override fun onIssued(data: String) {}
override fun onConfirm(data: String) = true
}).requestSubscription()kotlinfinal payload = Payload()
..webApplicationId = BootpayEnvConfig.webApplicationId
..androidApplicationId = BootpayEnvConfig.androidApplicationId
..iosApplicationId = BootpayEnvConfig.iosApplicationId
..pg = '나이스페이'
..method = '카드자동'
..subscriptionId = DateTime.now().millisecondsSinceEpoch.toString()
..price = 0
..orderName = '카드 등록'
..extra = (Extra()..subscribeTestPayment = 1);
Bootpay().requestSubscription(
context: context,
payload: payload,
onDone: (data) {
// data의 receipt_id를 백엔드 /api/billing-key/save로 전송
saveBillingKey(data);
},
onError: (data) => debugPrint('error: $data'),
onCancel: (data) => debugPrint('cancel: $data'),
onClose: () {},
);dartconst payload = {
pg: '나이스페이',
method: '카드자동',
order_name: '카드 등록',
subscription_id: `sub_${Date.now()}`,
price: 0,
}
const user = { username: '홍길동', phone: '01012345678' }
const extra = { subscribe_test_payment: true }
bootpay.current?.requestSubscription(payload, [], user, extra)
function onDone(data) {
// receipt_id를 백엔드 /api/billing-key/save로 전송
saveBillingKey(data.receipt_id)
}javascript상세 문서: 빌링키 발급
빌링키 발급 B — REST API로 직접 발급 (백엔드, 선택)
일부 PG는 백엔드 REST API로 빌링키를 직접 발급할 수 있어요. 이 방식은 가맹점 화면에서 카드 정보를 입력받아 서버로 전달하고, 서버가 Bootpay API에 빌링키 발급을 요청해요.
나이스페이먼츠, 페이앱, 웰컴페이먼츠, 토스페이먼츠, 키움페이
카드번호·유효기간·생년월일·비밀번호 앞 2자리를 가맹점 시스템이 직접 취급하므로, 지원 PG 여부와 보안 책임을 먼저 확인해야 해요. 가능하면 A 방식처럼 PG 결제창에서 입력받는 흐름을 우선 검토해요.
app.post('/api/billing-key/issue', auth, async (req, res) => {
const { card_no, card_pw, card_identity_no, card_expire_year, card_expire_month } = req.body
const info = await Bootpay.requestSubscribeBillingKey({
pg: '나이스페이',
subscription_id: `sub_${req.user.id}_${Date.now()}`,
order_name: '카드 등록',
card_no,
card_pw, // 비밀번호 앞 2자리
card_identity_no, // 생년월일 6자리 또는 사업자등록번호 10자리
card_expire_year, // YY
card_expire_month, // MM
})
await db.billingKeys.create({
user_id: req.user.id,
billing_key: info.billing_key,
card_name: info.card_name,
card_no_last4: info.card_no?.slice(-4),
status: 'active'
})
res.json({ success: true })
})javascript# Bootpay SDK 호출 (Flask·Django 등 핸들러 안에서)
info = bootpay.request_subscribe_billing_key(
pg='nicepay',
subscription_id=subscription_id,
order_name='카드 등록',
card_no=card_no,
card_pw=card_pw,
card_identity_no=card_identity_no,
card_expire_year=card_expire_year,
card_expire_month=card_expire_month,
)
# info['billing_key']를 DB에 저장python// Bootpay SDK 호출 (Laravel·Slim 등 컨트롤러 안에서)
$info = BootpayApi::requestSubscribeBillingKey([
'pg' => 'nicepay',
'subscription_id' => $subscriptionId,
'order_name' => '카드 등록',
'card_no' => $cardNo,
'card_pw' => $cardPw,
'card_identity_no' => $cardIdentityNo,
'card_expire_year' => $cardExpireYear,
'card_expire_month' => $cardExpireMonth,
]);
// $info['billing_key']를 DB에 저장php상세 문서: 백엔드에서 발급하기
빌링키 저장 (백엔드)
billing_keys 테이블 스키마는 자동결제와 동일해요 → 데이터 저장 설계.
아래 코드는 A 방식처럼 프론트엔드 결제창에서 받은 receipt_id로 빌링키를 조회해 저장하는 흐름이에요. B 방식은 발급 API 응답에 billing_key가 들어오므로 조회 단계 없이 바로 저장해야 해요.
Node.js는 저장 핸들러까지 포함하고, 다른 언어는 빌링키 조회 SDK 호출부만 보여줘요. 토큰 발급·DB 저장·라우팅은 각 프레임워크 컨벤션에 맞게 연결해요.
app.post('/api/billing-key/save', auth, async (req, res) => {
const { receipt_id } = req.body
// Bootpay에서 빌링키 조회
const info = await Bootpay.lookupSubscribeBillingKey(receipt_id)
// DB에 빌링키 저장
await db.billingKeys.create({
user_id: req.user.id,
billing_key: info.billing_key,
card_name: info.card_name, // "신한카드"
card_no_last4: info.card_no.slice(-4),
status: 'active'
})
res.json({ success: true })
})javascript# Bootpay SDK 호출 (Flask·Django 등 핸들러 안에서)
info = bootpay.lookup_subscribe_billing_key(receipt_id)
# info['billing_key'] / info['card_name'] / info['card_no']를 DB에 저장python// Bootpay SDK 호출 (Laravel·Slim 등 컨트롤러 안에서)
$info = BootpayApi::lookupSubscribeBillingKey($receiptId);
// $info['billing_key'] / $info['card_name'] / $info['card_no']를 DB에 저장php// Bootpay SDK 호출 (Spring 컨트롤러 안에서)
var info = bootpay.lookupBillingKey(receiptId);
// info.get("billing_key") / info.get("card_name") / info.get("card_no")를 DB에 저장java# Bootpay SDK 호출 (Rails 컨트롤러 안에서)
info = bootpay.request(method: :get, uri: "subscribe/billing_key/#{receipt_id}").data
# info['billing_key'] / info['card_name'] / info['card_no']를 DB에 저장ruby// Bootpay SDK 호출 (net/http·Gin 핸들러 안에서)
info, err := api.LookupBillingKey(receiptID)
if err != nil {
log.Fatal(err)
}
// info["billing_key"] / info["card_name"] / info["card_no"]를 DB에 저장go// Bootpay SDK 호출 (ASP.NET Core 컨트롤러 안에서)
var infoResponse = await bootpay.LookupBillingKey(receiptId);
var infoJson = await infoResponse.Content.ReadAsStringAsync();
// infoJson에서 data.billing_key / card_name / card_no를 추출해 DB에 저장csharp상세 문서: 빌링키 조회 — 7개 언어 SDK 초기화와 응답 스키마를 확인해요.
예약 결제 등록 (백엔드)
feedback_url(또는 관리자 통합 웹훅 URL)이 없으면 예약 등록이SR_WEBHOOK_URL_BLANK로 거절돼요.reserve_execute_at은 현재로부터 2개월 이내여야 하고, 빌링키 만료일보다 뒤일 수 없어요.order_id는 같은 빌링키 안에서 재사용할 수 없어요. 취소·완료된 예약의 값도 마찬가지예요.
app.post('/api/reserve-payment', auth, async (req, res) => {
const { amount, execute_at, description } = req.body
// 사용자의 빌링키 조회
const billingKey = await db.billingKeys.findOne({
where: { user_id: req.user.id, status: 'active' }
})
if (!billingKey) {
return res.status(400).json({ error: '등록된 카드가 없습니다' })
}
// Bootpay API 호출 → 부트페이가 예약을 보관했다가 예약 시각에 실행
const result = await Bootpay.subscribePaymentReserve({
billing_key: billingKey.billing_key,
price: amount,
order_name: description,
order_id: 'reserve_' + Date.now(),
reserve_execute_at: execute_at, // '2025-03-01T09:00:00 +0900', 2개월 이내
feedback_url: 'https://example.com/webhooks/bootpay'
})
// 예약 정보 저장
await db.reservePayments.create({
user_id: req.user.id,
reserve_id: result.reserve_id,
amount,
execute_at,
description,
status: 'reserved'
})
res.json({
success: true,
reserve_id: result.reserve_id,
execute_at: result.reserve_execute_at
})
})javascript# Bootpay SDK 호출 (Flask·Django 핸들러 안에서)
result = bootpay.subscribe_payment_reserve(
billing_key=billing_key,
price=amount,
order_name=description,
order_id=f'reserve_{int(time.time())}',
reserve_execute_at=execute_at,
)
# result['reserve_id']를 reserve_payments 테이블에 저장python// Bootpay SDK 호출 (Laravel·Slim 컨트롤러 안에서)
$result = BootpayApi::subscribePaymentReserve([
'billing_key' => $billingKey,
'price' => $amount,
'order_name' => $description,
'order_id' => 'reserve_' . time(),
'reserve_execute_at' => $executeAt,
]);
// $result['reserve_id']를 reserve_payments 테이블에 저장php// Bootpay SDK 호출 (Spring 컨트롤러 안에서)
SubscribePayload payload = new SubscribePayload();
payload.billingKey = billingKey;
payload.price = amount;
payload.orderName = description;
payload.orderId = "reserve_" + System.currentTimeMillis();
payload.reserveExecuteAt = executeAt;
var result = bootpay.reserveSubscribe(payload);
// result.get("reserve_id")를 reserve_payments 테이블에 저장java# Bootpay SDK 호출 (Rails 컨트롤러 안에서)
result = bootpay.request(
uri: 'subscribe/payment/reserve',
payload: {
billing_key: billing_key,
price: amount,
order_name: description,
order_id: "reserve_#{Time.now.to_i}",
reserve_execute_at: execute_at
}
).data
# result['reserve_id']를 reserve_payments 테이블에 저장ruby// Bootpay SDK 호출 (net/http·Gin 핸들러 안에서)
result, err := api.ReserveSubscribe(bootpay.SubscribePayload{
BillingKey: billingKey,
Price: amount,
OrderName: description,
OrderId: fmt.Sprintf("reserve_%d", time.Now().Unix()),
ReserveExecuteAt: executeAt,
})
if err != nil {
log.Fatal(err)
}
// result.ReserveID를 reserve_payments 테이블에 저장go// Bootpay SDK 호출 (ASP.NET Core 컨트롤러 안에서)
var result = await bootpay.ReserveSubscribe(new SubscribePayload
{
billingKey = billingKey,
price = amount,
orderName = description,
orderId = $"reserve_{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}",
reserveExecuteAt = executeAt,
});
// result.ReserveId를 reserve_payments 테이블에 저장csharp상세 문서: 결제 예약 — 7개 언어 예제와 응답 스키마를 확인해요.
웹훅 수신 — 예약 실행 결과 (백엔드)
예약 시간이 되면 부트페이가 저장된 빌링키로 PG사에 결제를 요청하고, 그 결과를 가맹점 서버로 웹훅으로 보내줘요.
웹훅 본문은 event·data로 감싸여 오지 않고 평면 객체로 와요. 성공과 실패는 서로 다른 형태로 오기 때문에, error_code가 있는지로 먼저 갈라야 해요.
app.post('/webhooks/bootpay', async (req, res) => {
res.status(200).json({ success: true })
// 실패: { error_code, pg_error_code, message, payload: { order_id, reserve_id, metadata } }
if (req.body.error_code) {
const { reserve_id } = req.body.payload || {}
await db.reservePayments.update(
{ status: 'failed', fail_reason: req.body.message },
{ where: { reserve_id } }
)
// 고객에게 결제 실패 알림 — 부트페이는 자동으로 재시도하지 않아요
return
}
// 성공: { receipt_id, reserve_id, order_id, price, status, ... }
const { receipt_id, reserve_id, status } = req.body
if (reserve_id && status === 1) {
// receipt_id로 결제를 다시 조회해 금액을 검증한 뒤 확정해요
await db.reservePayments.update(
{
status: 'done',
receipt_id,
paid_at: new Date()
},
{ where: { reserve_id } }
)
}
})javascript성공 본문의 status는 결제 상태값으로 1이 결제 완료예요. 예약 자체의 상태(0 대기 / 1 완료 / -1 실패 / 3 취소)는 예약 조회로 확인하는 값이라 서로 다른 체계예요.
실행에 실패하면 위처럼 error_code가 담긴 웹훅이 오지만, 이 본문에는 receipt_id가 없고 매칭 키가 payload.reserve_id에 들어 있어요. 관리자에서 오류 웹훅을 켰는지 여부에 따라 본문을 만드는 주체와 세부 필드도 달라져요.
그리고 실패한 예약은 자동으로 재시도되지 않아요. 상태가 -1로 고정되므로, 실행 예정 시각이 지난 예약을 예약 조회로 확인해 status가 -1인 건을 찾아내는 폴링을 함께 두는 편이 안전해요. 재청구가 필요하면 새 order_id로 예약을 다시 등록하거나 빌링키 결제를 직접 호출해야 해요.
상세 문서: 웹훅 처리 가이드
예약 취소 (백엔드, 선택)
예약 취소는 부트페이에 예약된 실행 작업을 삭제하는 것이에요. 아직 실행되지 않은 예약에만 쓸 수 있고, 이미 결제가 실행된 뒤에는 예약 취소가 아니라 receipt_id 기준 결제 취소를 호출해야 해요.
app.post('/api/reserve-payment/:id/cancel', auth, async (req, res) => {
const reserve = await db.reservePayments.findByPk(req.params.id)
if (!reserve || reserve.user_id !== req.user.id) {
return res.status(404).json({ error: '예약을 찾을 수 없음' })
}
if (reserve.status !== 'reserved') {
return res.status(400).json({ error: '취소할 수 없는 상태' })
}
await Bootpay.cancelSubscribeReserve(reserve.reserve_id)
await db.reservePayments.update(
{ status: 'cancelled' },
{ where: { id: reserve.id } }
)
res.json({ success: true })
})javascript# Bootpay SDK 호출
response = bootpay.cancel_subscribe_reserve(reserve_id)python// Bootpay SDK 호출
$response = BootpayApi::cancelSubscribeReserve($reserveId);php// Bootpay SDK 호출
var response = bootpay.reserveCancelSubscribe(reserveId);java# Bootpay SDK 호출
response = bootpay.request(method: :delete, uri: "subscribe/payment/reserve/#{reserve_id}").dataruby// Bootpay SDK 호출
response, err := api.ReserveCancelSubscribe(reserveID)
if err != nil {
log.Fatal(err)
}go// Bootpay SDK 호출
var response = await bootpay.ReserveCancelSubscribe(reserveId);csharp상세 문서: 예약 취소 — 7개 언어 예제와 에러 코드를 확인해요.
다음 단계
- 예약 조회·취소·운영 주의사항을 자세히 보려면 → 예약결제 상세 문서
- 결제수단 변경·빌링키 삭제 순서를 정리하려면 → 예약결제 운영 주의사항
- 매월 자동으로 반복 결제하려면 → 자동결제
더 읽을거리
- 결제수단 선택 설계 — 카드 등록 UX·동의 문구
- 결제 실패 재시도 정책 — 예약 실패 시 대응
