"웹훅 받으면 뭘 해야 해?" — 이벤트별로 가맹점이 수행해야 하는 비즈니스 로직을 정리해요.
웹훅 빠른 매뉴얼 — 최소 코드로 시작해요. 이 페이지는 전체 이벤트와 고급 패턴(멱등성, DB 설계)을 다루는 Full Reference예요.
웹훅 설정에서 URL 등록과 기본 코드를 확인해요. 이 문서는 설정 이후, 각 이벤트를 받았을 때 실제로 무엇을 해야 하는지에 집중해요.
| # | 조건 | 예시 |
|---|---|---|
| 1 | HTTP 상태 코드 200 반환 | res.status(200) |
| 2 | JSON 응답 본문에 { "success": true } 포함 |
res.json({ success: true }) |
두 조건 중 하나라도 빠지면 Bootpay는 웹훅이 실패한 것으로 판단하고 재시도해요.
결제 웹훅
상태별 처리
| 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.payment_pending |
지연 결제 대기 | 입금 안내 표시 | 권장 |
order.payment_error |
결제 실패 | 에러 로그 기록 | 선택 |
구독 이벤트
| 이벤트 | 발생 시점 | 가맹점이 할 일 | 중요도 |
|---|---|---|---|
subscription.requested_done |
구독 신청 접수 | 관리자에게 승인 요청 알림 | 선택 |
subscription.approved |
구독 승인 | 서비스 활성화 (service_active = true) |
필수 |
subscription.renewed |
회차 결제 성공 | 서비스 기간 연장, 다음 결제일 업데이트 | 필수 |
subscription.modified |
구독 내용 변경 | 변경 내용 반영 (금액, 상품) | 선택 |
subscription.paused |
구독 일시정지 | 서비스 일시 제한 or 유지 (정책에 따라) | 권장 |
subscription.resumed |
구독 재개 | 서비스 재활성화 | 권장 |
subscription.terminated |
구독 해지 (고객) | 서비스 비활성화 (service_active = false) |
필수 |
subscription.admin_terminated |
구독 해지 (관리자) | 서비스 비활성화 + 고객 안내 | 필수 |
Bootpay 내부 이벤트 매핑 참조
웹훅 이벤트명은 Bootpay 내부 WebhookType 상수에서 변환돼요. 디버깅이나 로그 분석 시 참고해요.
주문 이벤트
| 내부 WebhookType | 코드 | 이벤트명 | 설명 |
|---|---|---|---|
| ORDER_DONE | 202 | order.done |
주문 결제 완료 |
| ORDER_CONFIRM_ERROR | 203 | order.payment_error |
결제 실패 |
| ORDER_CANCELLED | 204 | order.cancelled |
주문 전체 취소 |
| ORDER_TRANSACTION_PENDING | 206 | order.payment_pending |
지연 결제 대기 (무통장 등) |
| ORDER_PARTIAL_CANCELLED | 207 | order.partial_cancelled |
부분 취소 |
구독 이벤트
| 내부 WebhookType | 코드 | 이벤트명 | 설명 |
|---|---|---|---|
| SUBSCRIPTION_REQUESTED_DONE | 100 | subscription.requested_done |
구독 신청 접수 |
| SUBSCRIPTION_APPROVED | 101 | subscription.approved |
구독 승인 |
| SUBSCRIPTION_TRIAL_STARTED | 103 | subscription.trial_started |
체험 시작 |
| SUBSCRIPTION_RENEWED | 150 | subscription.renewed |
회차 결제 성공 (갱신) |
| SUBSCRIPTION_MODIFIED | 120 | subscription.modified |
구독 내용 변경 |
| SUBSCRIPTION_PAUSED | 130 | subscription.paused |
구독 일시정지 |
| SUBSCRIPTION_RESUMED | 131 | subscription.resumed |
구독 재개 |
| SUBSCRIPTION_TERMINATED | 140 | subscription.terminated |
구독 해지 (고객) |
| SUBSCRIPTION_ADMIN_TERMINATED | 141 | subscription.admin_terminated |
구독 해지 (관리자) |
| SUBSCRIPTION_EXPIRED | 142 | subscription.expired |
약정 만료 |
커머스 웹훅 코드 패턴
패턴 1: 200 먼저, 처리는 비동기
웹훅은 30초 이내 응답해야 해요. 비즈니스 로직은 비동기로 처리해요.
app.post('/webhooks/bootpay', async (req, res) => {
const { idempotency_key, event, data } = 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
}
await db.webhookLogs.update({ status: 'processed' }, { where: { idempotency_key: key } })
}javascript@app.route('/webhooks/bootpay', methods=['POST'])
def webhook():
payload = request.get_json()
idempotency_key = payload['idempotency_key']
event = payload['event']
data = payload['data']
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'];
if (WebhookLog::where('idempotency_key', $idempotencyKey)->exists()) {
return response()->json(['success' => true]);
}
WebhookLog::create([
'idempotency_key' => $idempotencyKey,
'event' => $payload['event'],
'status' => 'received'
]);
ProcessWebhookJob::dispatch($payload['event'], $payload['data'], $idempotencyKey);
return response()->json(['success' => true]);
});php패턴 2: 이벤트별 처리 함수
// order.done — 주문 결제 완료
async function handleOrderDone(data) {
const { order_id, order_number, price } = data
await db.orders.update(
{ status: 'paid', bootpay_order_id: order_id, 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 — 구독 승인
async function handleSubscriptionApproved(data) {
await db.subscriptions.update(
{ service_active: true, status: 'active' },
{ where: { bootpay_subscription_id: data.order_subscription_id } }
)
}
// 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.next_billing_at), status: 'active', service_active: true },
{ where: { bootpay_subscription_id: data.order_subscription_id } }
)
}javascriptsubscription.renewed는 매 회차 결제가 성공할 때마다 발송돼요. next_billing_at 값으로 가맹점 DB의 current_period_end를 갱신하면, 서비스 접근 제어를 정확하게 관리할 수 있어요.
패턴 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) {
const { order_number, cancel_price, remain_price } = data
await db.orders.update(
{ total_amount: remain_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디버깅: 웹훅이 안 올 때
| # | 확인 사항 | 해결 방법 |
|---|---|---|
PROCESS_DUPLICATED |
이미 처리된 요청이에요 | 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 관리자 웹훅 설정에 등록하면 로컬에서도 웹훅을 수신할 수 있어요.
