웹훅 빠른 매뉴얼 — 최소 코드로 시작해요. 이 페이지는 전체 이벤트와 고급 패턴(멱등성, DB 설계)을 다루는 Full Reference예요.
주문 웹훅 설정에서 URL 등록과 기본 코드를 확인해요. 이 문서는 설정 이후, 각 이벤트를 받았을 때 실제로 무엇을 해야 하는지에 집중해요.
| # | 조건 | 예시 |
|---|---|---|
| 1 | HTTP 상태 코드 200 반환 | res.status(200) |
| 2 | JSON 응답 본문에 { "success": true } 포함 |
res.json({ success: true }) |
두 조건 중 하나라도 빠지면 Bootpay는 웹훅이 실패한 것으로 판단하고 재시도해요.
결제 웹훅
상태별 처리 (status)
결제 웹훅의 status는 숫자예요 (커머스 API 의 주문 status 와 다른 값 체계).
| status | 설명 | 가맹점이 할 일 | 중요도 |
|---|---|---|---|
| 1 | 결제 완료 | 주문 상태 업데이트, 서비스 활성화 | 필수 |
| 20 | 결제 취소 | 재고 복구, 환불 처리 | 필수 |
| 2 | 가상계좌 발급 (입금 대기) | 입금 안내 표시 | 권장 |
결제 웹훅 처리 코드
app.post('/webhook/bootpay', async (req, res) => {
const { receipt_id, status, price, order_id } = req.body
// 1. 결제 조회 API로 검증 (위변조 방지)
const receipt = await Bootpay.getReceipt(receipt_id)
if (receipt.status !== status || receipt.price !== price) {
return res.status(200).json({ success: true })
}
// 2. 상태별 처리
switch (status) {
case 1: // 결제 완료
await db.orders.update({
bootpay_receipt_id: receipt_id,
amount: price,
method: receipt.method,
status: 'paid',
paid_at: new Date()
}, { where: { order_id } })
// 재고 차감
const order = await db.orders.findOne({ where: { order_id } })
for (const item of order.items) {
await db.products.decrement('stock', {
by: item.quantity, where: { id: item.product_id }
})
}
break
case 20: // 결제 취소
await db.orders.update(
{ status: 'refunded' },
{ where: { bootpay_receipt_id: receipt_id } }
)
// 재고 복구
const refundOrder = await db.orders.findOne({
where: { bootpay_receipt_id: receipt_id }
})
for (const item of refundOrder.items) {
await db.products.increment('stock', {
by: item.quantity, where: { id: item.product_id }
})
}
break
case 2: // 가상계좌 발급 (입금 대기)
await db.orders.update(
{ status: 'pending', bootpay_receipt_id: receipt_id },
{ where: { order_id } }
)
break
}
res.status(200).json({ success: true })
})javascript결제 웹훅의 receipt_id를 사용해 Bootpay 서버에서 직접 영수증을 조회하여, 금액(price)과 상태(status)가 일치하는지 확인해야 해요.
가상계좌는 웹훅이 2번 온다: 발급 시(status: 2) → 입금 완료 시(status: 1). 두 이벤트를 모두 처리해야 해요.
커머스 웹훅
이벤트별 가맹점 액션
주문 이벤트
| 이벤트 | 발생 시점 | 가맹점이 할 일 | 중요도 |
|---|---|---|---|
order.done |
주문 결제 완료 | 재고 차감, 서비스 활성화, 확인 이메일 | 필수 |
order.cancelled |
주문 취소 완료 | 재고 복구, 서비스 비활성화, 환불 안내 | 필수 |
order.partial_cancelled |
부분 취소 | 해당 상품만 재고 복구 | 권장 |
order.transaction_pending |
지연 결제 대기 | 입금 안내 표시 | 권장 |
order.confirm_error |
결제 승인 실패 | 에러 로그 기록, 주문 실패 처리 | 선택 |
order.cancel_error |
취소 처리 실패 | 재고를 되돌리지 말고 운영자에게 알려요 | 권장 |
order.partial_cancelled 는 지금 payload·idempotency_key 없이 webhook_type 만 담겨 와요 — 받으면 주문 상세를 다시 조회해 cancelled_price 를 반영해요. order.transaction_pending 은 지금 발송되지 않아요 — 입금 대기는 주문 상세의 status 로 확인해요.
구독 이벤트
| 이벤트 | 발생 시점 | 가맹점이 할 일 | 중요도 |
|---|---|---|---|
subscription.requested_done |
구독 신청 접수 | 관리자에게 승인 요청 알림 | 선택 |
subscription.approved |
구독 승인 | 서비스 활성화 (service_active = true) |
필수 |
subscription.renewed |
회차 결제 성공 | 서비스 기간 연장, 다음 결제일 업데이트 | 필수 |
subscription.modified |
구독 내용 변경 | 변경 내용 반영 (금액, 상품) | 선택 |
subscription.rejected |
관리자가 구독 신청을 거절 | 서비스를 켜지 않고 고객 안내 | 권장 |
subscription.paused |
구독 일시정지 (관리자) | 서비스 일시 제한 or 유지 (정책에 따라) | 권장 |
subscription.resumed |
구독 재개 | 서비스 재활성화 | 권장 |
subscription.hold_on |
회차 결제 실패로 자동 중단 | 서비스 제한 + 복구 결제 안내 | 필수 |
subscription.terminated |
결제 실패 반복으로 자동 해지 | 서비스 비활성화 (service_active = false) |
필수 |
subscription.admin_terminated |
구독 해지 (관리자) | 서비스 비활성화 + 고객 안내 | 필수 |
subscription.restored |
관리자가 해지를 되돌림 | 서비스 재활성화 | 필수 |
subscription.renewed(와 아래 표의renewal_upcoming·expired·expiring_soon·transfer_*·trial_*)는 정의만 있고 실제로 나가지 않아요. 회차 결제 결과는 회차 조회로, 다음 결제일은 계약 상세의payment_next_at으로, 만료·체험 상태는 계약 조회의status로 주기적으로 확인해요.subscription.terminated는 결제 실패가 반복돼 자동 해지될 때만 나가요. 고객이 요청한 해지는 승인돼도 웹훅이 없어요.subscription.paused는 관리자 일시정지 때만 나가요. 고객이 요청한 일시정지는 자동 승인이든 관리자 승인이든 웹훅이 없어요.- 고객 해지·일시정지는 계약 상세의
status나 구독 요청 이력으로 주기적으로 확인해요.
Bootpay 내부 이벤트 매핑 참조
웹훅 이벤트명은 Bootpay 내부 WebhookType 상수에서 변환돼요. 디버깅이나 로그 분석 시 참고해요.
주문 이벤트 (내부 상수 — 응답 필드가 아니에요)
| 내부 WebhookType | 코드 | 이벤트명 | 설명 |
|---|---|---|---|
| ORDER_DONE | 202 | order.done |
주문 결제 완료 |
| ORDER_CONFIRM_ERROR | 203 | order.confirm_error |
결제 승인 실패 |
| ORDER_CANCELLED | 204 | order.cancelled |
주문 전체 취소 |
| ORDER_CANCEL_ERROR | 205 | order.cancel_error |
취소 처리 실패 |
| ORDER_TRANSACTION_PENDING | 206 | order.transaction_pending |
지연 결제 대기 (무통장 등) |
| ORDER_PARTIAL_CANCELLED | 207 | order.partial_cancelled |
부분 취소 |
구독 이벤트 (내부 상수 — 응답 필드가 아니에요)
| 내부 WebhookType | 코드 | 이벤트명 | 설명 |
|---|---|---|---|
| SUBSCRIPTION_REQUESTED_DONE | 100 | subscription.requested_done |
구독 신청 접수 |
| SUBSCRIPTION_APPROVED | 101 | subscription.approved |
구독 승인 |
| SUBSCRIPTION_REQUESTED_ERROR | 102 | subscription.requested_error |
구독 신청 오류 |
| SUBSCRIPTION_TRIAL_STARTED | 103 | subscription.trial_started |
체험 시작 |
| SUBSCRIPTION_TRIAL_ENDING | 104 | subscription.trial_ending |
체험 종료 예정 (3일 전) |
| SUBSCRIPTION_TRIAL_ENDED | 105 | subscription.trial_ended |
체험 종료·정식 전환 |
| SUBSCIPTION_REJECTED | 106 | subscription.rejected |
구독 거절 |
| SUBSCRIPTION_MODIFIED | 120 | subscription.modified |
구독 내용 변경 |
| SUBSCRIPTION_UPGRADED | 121 | subscription.upgraded |
플랜 업그레이드 |
| SUBSCRIPTION_DOWNGRADED | 122 | subscription.downgraded |
플랜 다운그레이드 |
| SUBSCRIPTION_TRANSFER_REQUESTED | 123 | subscription.transfer_requested |
중도인수 요청 |
| SUBSCRIPTION_TRANSFER_APPROVED | 124 | subscription.transfer_approved |
중도인수 승인 |
| SUBSCRIPTION_PAUSED | 130 | subscription.paused |
구독 일시정지 (관리자) |
| SUBSCRIPTION_RESUMED | 131 | subscription.resumed |
구독 재개 |
| SUBSCRIPTION_HOLD_ON | 132 | subscription.hold_on |
결제 실패로 자동 중단 |
| SUBSCRIPTION_TERMINATED | 140 | subscription.terminated |
결제 실패 반복으로 자동 해지 |
| SUBSCRIPTION_ADMIN_TERMINATED | 141 | subscription.admin_terminated |
구독 해지 (관리자) |
| SUBSCRIPTION_EXPIRED | 142 | subscription.expired |
약정 만료 |
| SUBSCRIPTION_EXPIRING_SOON | 143 | subscription.expiring_soon |
만료 예정 알림 (7일 전) |
| SUBSCRIPTION_RESTORED | 144 | subscription.restored |
관리자 해지 복구 |
| SUBSCRIPTION_RENEWED | 150 | subscription.renewed |
회차 결제 성공 (갱신) |
| SUBSCRIPTION_RENEWAL_UPCOMING | 151 | subscription.renewal_upcoming |
갱신 예정 알림 |
subscription_coupon.applied · .removed · .expired 는 같은 URL 로 와요.
알림톡(alimtalk.*)은 별개 웹훅이라 알림톡 웹훅 설정에서 따로 등록해요.
일부 경로는 봉투(payload) 없이 주문 필드가 그대로 펼쳐진 본문을 보내고, webhook_type 이
ORDER_COMPLETED · ORDER_CANCELLED · ORDER_PARTIAL_CANCELLED · ORDER_VIRTUAL_ACCOUNT_ISSUED 처럼 대문자로 와요.
아래 패턴처럼 body.payload ?? body 로 두 모양을 함께 받으면 안전해요.
커머스 웹훅 코드 패턴
패턴 1: 200 먼저, 처리는 비동기
웹훅은 30초 이내 응답해야 해요. 비즈니스 로직은 비동기로 처리해요.
app.post('/webhooks/bootpay', async (req, res) => {
const { idempotency_key, webhook_type: event } = req.body
// 구형(봉투 없는) 본문도 함께 받아요
const data = req.body.payload ?? req.body
// 1. 중복 체크
const exists = await db.webhookLogs.findOne({ where: { idempotency_key } })
if (exists) return res.status(200).json({ success: true })
// 2. 웹훅 로그 저장
await db.webhookLogs.create({ idempotency_key, event, data: JSON.stringify(data), status: 'received' })
// 3. 즉시 200 반환
res.status(200).json({ success: true })
// 4. 비동기로 처리
processWebhook(event, data, idempotency_key).catch(err => {
console.error('웹훅 처리 실패:', err)
})
})
async function processWebhook(event, data, key) {
switch (event) {
case 'order.done': await handleOrderDone(data); break
case 'order.cancelled': await handleOrderCancelled(data); break
case 'subscription.approved': await handleSubscriptionApproved(data); break
case 'subscription.renewed': await handleSubscriptionRenewed(data); break
case 'subscription.terminated':
case 'subscription.admin_terminated':
await handleSubscriptionTerminated(data); break
case 'order.partial_cancelled':
await handleOrderPartialCancelled(data); break
case 'subscription.hold_on':
await handleSubscriptionHoldOn(data); break
}
await db.webhookLogs.update({ status: 'processed' }, { where: { idempotency_key: key } })
}javascript@app.route('/webhooks/bootpay', methods=['POST'])
def webhook():
body = request.get_json()
idempotency_key = body.get('idempotency_key')
event = body['webhook_type']
# 구형(봉투 없는) 본문도 함께 받아요
data = body.get('payload', body)
if WebhookLog.query.filter_by(idempotency_key=idempotency_key).first():
return jsonify({'success': True}), 200
WebhookLog.create(idempotency_key=idempotency_key, event=event, status='received')
thread = threading.Thread(target=process_webhook, args=(event, data, idempotency_key))
thread.start()
return jsonify({'success': True}), 200python<?php
Route::post('/webhooks/bootpay', function (Request $request) {
$payload = $request->all();
$idempotencyKey = $payload['idempotency_key'] ?? null;
// 구형(봉투 없는) 본문도 함께 받아요
$data = $payload['payload'] ?? $payload;
if (WebhookLog::where('idempotency_key', $idempotencyKey)->exists()) {
return response()->json(['success' => true]);
}
WebhookLog::create([
'idempotency_key' => $idempotencyKey,
'event' => $payload['webhook_type'],
'status' => 'received'
]);
ProcessWebhookJob::dispatch($payload['webhook_type'], $data, $idempotencyKey);
return response()->json(['success' => true]);
});php패턴 2: 이벤트별 처리 함수
// order.done — 주문 결제 완료
async function handleOrderDone(data) {
const { order_number, price } = data // payload 에 order_id 는 없어요 — order_number 로 맞춰요
await db.orders.update(
{ status: 'paid', paid_at: new Date() },
{ where: { bootpay_order_number: order_number } }
)
// 재고 차감
const order = await db.orders.findOne({ where: { bootpay_order_number: order_number } })
for (const item of order.items) {
await db.products.decrement('stock', { by: item.quantity, where: { id: item.product_id } })
}
await sendEmail(order.user_id, 'order_confirmed', { order_number, price })
}
// order.cancelled — 주문 취소
async function handleOrderCancelled(data) {
const { order_number } = data
await db.orders.update({ status: 'cancelled' }, { where: { bootpay_order_number: order_number } })
const order = await db.orders.findOne({ where: { bootpay_order_number: order_number } })
for (const item of order.items) {
await db.products.increment('stock', { by: item.quantity, where: { id: item.product_id } })
}
}
// subscription.approved — 구독 승인
// 신청 결제와 함께 승인되면 주문 모양(order_subscriptions[]), 관리자 승인이면 구독 모양(order_subscription_id)으로 와요
async function handleSubscriptionApproved(data) {
const ids = data.order_subscription_id
? [data.order_subscription_id]
: (data.order_subscriptions ?? []).map(s => s.order_subscription_id)
await db.subscriptions.update(
{ service_active: true, status: 'active' },
{ where: { bootpay_subscription_id: ids } }
)
}
// subscription.terminated — 구독 해지
async function handleSubscriptionTerminated(data) {
await db.subscriptions.update(
{ service_active: false, status: 'terminated' },
{ where: { bootpay_subscription_id: data.order_subscription_id } }
)
}
// subscription.renewed — 구독 갱신 (회차 결제 성공)
async function handleSubscriptionRenewed(data) {
await db.subscriptions.update(
{ current_period_end: new Date(data.payment_next_at), status: 'active', service_active: true },
{ where: { bootpay_subscription_id: data.order_subscription_id } }
)
}javascriptsubscription.renewed는 매 회차 결제가 성공할 때마다 발송돼요. payment_next_at(다음 결제 예정일) 값으로 가맹점 DB의 current_period_end를 갱신하면, 서비스 접근 제어를 정확하게 관리할 수 있어요. (지금은 발송되지 않아요 — 위 알려진 문제 참고)
구독 해지 복구 (restore)
subscription.restored 는 가맹점이 복구를 알 수 있는 유일한 통로예요. 해지 웹훅(terminated·admin_terminated)을 받고 이미 서비스를 껐을 텐데, 복구는 부트페이 관리자에서 처리되고 가맹점이 부를 수 있는 API 가 없어요.
이 이벤트에만 payload.restore 블록이 함께 실려요.
| 필드 | 설명 |
|---|---|
mode |
복구 방식 |
restored_at |
복구를 실행한 시각 |
terminated_at |
원래 해지됐던 시각 |
terminated_at_source |
해지 시각을 어디서 판정했는지 |
previous_status |
해지 직전 상태 |
restored_status |
복구 후 상태 |
restored_bill_ids |
되살아난 회차 ID 배열 |
next_payment_at |
복구 후 다음 결제 예정일 |
extend_service_end |
서비스 종료일을 미뤘는지 |
charge_missed_bills |
밀린 회차를 청구할지 |
skipped_cycles |
해지 기간 동안 건너뛴 회차 수 |
repaid |
재결제·환불 처리 여부 |
reason |
관리자가 남긴 복구 사유 |
// subscription.restored — 관리자 해지 복구
async function handleSubscriptionRestored(data) {
const r = data.restore ?? {}
await db.subscriptions.update(
{
service_active: true,
status: 'active',
current_period_end: r.next_payment_at ? new Date(r.next_payment_at) : undefined
},
{ where: { bootpay_order_number: data.order_number } }
)
// 해지 기간 동안 건너뛴 회차가 있으면 정산·이용권 계산에 반영해요
if (r.skipped_cycles > 0) {
await reconcileSkippedCycles(data.order_number, r.skipped_cycles, r.charge_missed_bills)
}
}javascript복구는 밀린 회차를 건너뛰거나 서비스 종료일을 미루면서 다음 결제일을 옮겨요. 해지 전 값을 그대로 되살리면 이용 기간이 어긋나요.
패턴 3: 환불 이벤트 처리 (전체/부분)
order.cancelled와 order.partial_cancelled는 처리 로직이 달라요.
// order.cancelled — 전체 취소/환불
async function handleOrderCancelled(data) {
const { order_number } = data
await db.orders.update({ status: 'refunded' }, { where: { bootpay_order_number: order_number } })
// 전체 재고 복구
}
// order.partial_cancelled — 부분 취소/환불
async function handleOrderPartialCancelled(data) {
// 지금은 payload 가 비어 와요(알려진 문제) — 주문 상세를 다시 조회해 같은 값을 읽어요
const { order_number, price, cancelled_price } = data
await db.orders.update(
{ total_amount: price - cancelled_price, status: 'partially_refunded' },
{ where: { bootpay_order_number: order_number } }
)
// 부분 취소된 상품만 재고 복구
}javascriptorder.cancelled→ 전체 환불. 주문 무효화.order.partial_cancelled→ 일부 환불. 주문은 유효하며 잔액이 남아있어요.
웹훅 DB 테이블 (멱등성 보장)
웹훅 로그 테이블을 만들어 idempotency_key 기반으로 중복을 방지해요.
CREATE TABLE webhook_logs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
idempotency_key VARCHAR(100) NOT NULL UNIQUE,
event VARCHAR(50) NOT NULL,
data TEXT,
status VARCHAR(20) DEFAULT 'received', -- received → processed → failed
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_key (idempotency_key),
INDEX idx_event (event)
);sql디버깅: 웹훅이 안 올 때
| # | 확인 사항 | 해결 방법 |
|---|---|---|
| 1 | 웹훅 URL이 HTTPS인가? | HTTP는 지원하지 않음. SSL 인증서 확인 |
| 2 | 관리자에서 이벤트를 선택했는가? | 관리자 → 웹훅 설정에서 필요한 이벤트 체크 |
| 3 | 서버가 200을 반환하는가? | 다른 상태 코드면 재시도로 처리됨 |
| 4 | 방화벽이 차단하지 않는가? | Bootpay IP 대역 허용 |
| 5 | 로컬 개발 환경인가? | ngrok 등 터널링 도구 사용 |
| 6 | 테스트 웹훅을 발송해봤는가? | 웹훅 설정에서 테스트 웹훅 발송 기능 사용 |
| 7 | 샌드박스 모드인가? | 샌드박스에서도 웹훅은 정상 발송됨 |
로컬 개발 시 웹훅 받기
로컬 개발 환경에서는 외부에서 접근할 수 없으므로 터널링 도구를 사용해요.
# ngrok 사용
ngrok http 3000
# → https://abc123.ngrok.io 주소를 웹훅 URL로 등록
# 또는 localtunnel
npx localtunnel --port 3000bash터널링 URL을 Bootpay 관리자 웹훅 설정에 등록하면 로컬에서도 웹훅을 수신할 수 있어요.
