서버 연동

웹훅 처리 가이드

이벤트를 받았을 때 서버가 무엇을 해야 하는지 정리해요.

빠르게 시작하려면?

웹훅 빠른 매뉴얼 — 최소 코드로 시작해요. 이 페이지는 전체 이벤트와 고급 패턴(멱등성, 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
반드시 결제 조회 API로 검증해요

결제 웹훅의 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 가 오지 않고, 고객 해지·일시정지도 웹훅이 없어요
  • 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 갱신 예정 알림
구독 쿠폰(160~162)·알림톡(300~320) 이벤트도 있어요

subscription_coupon.applied · .removed · .expired 는 같은 URL 로 와요. 알림톡(alimtalk.*)은 별개 웹훅이라 알림톡 웹훅 설정에서 따로 등록해요.

대문자 `webhook_type` 도 올 수 있어요

일부 경로는 봉투(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

패턴 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 } }
    )
}javascript
subscription.renewed 활용

subscription.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
`next_payment_at` 을 반드시 다시 읽어요

복구는 밀린 회차를 건너뛰거나 서비스 종료일을 미루면서 다음 결제일을 옮겨요. 해지 전 값을 그대로 되살리면 이용 기간이 어긋나요.

패턴 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 } }
    )
    // 부분 취소된 상품만 재고 복구
}javascript
전체 취소 vs 부분 취소 구분
  • order.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 관리자 웹훅 설정에 등록하면 로컬에서도 웹훅을 수신할 수 있어요.