서버 연동

웹훅 이벤트 처리

웹훅 이벤트별로 가맹점이 수행할 처리를 정리합니다. URL 등록과 기본 코드는 웹훅 설정에서 확인합니다.

웹훅 응답 필수 조건 — 응답 본문의 success 값만 봅니다
# 조건 예시
1 JSON 응답 본문에 { "success": true } 포함 res.json({ success: true })

성공/실패 판정은 오직 응답 본문의 success 값​​으로 이뤄집니다. HTTP 상태 코드는 로그에만 기록될 뿐 판정에 쓰이지 않습니다. 즉 HTTP 200을 반환해도 본문에 success: true가 없으면 실패로 기록되고 재시도​​됩니다. (success는 boolean true 또는 문자열 "true" 모두 인정됩니다.)

HTTP 200과 함께 반환하되, 판정 기준은 본문입니다.

결제 웹훅

상태별 처리

status webhook_type 설명 가맹점이 할 일 중요도
1 PAYMENT_COMPLETED 결제완료 주문 상태 업데이트, 서비스 활성화 필수
20 PAYMENT_CANCELLED 결제 전체 취소 (잔액 0) 취소 상태 반영, 서비스 비활성화, 재고 복구 필수
1 PAYMENT_PARTIAL_CANCELLED 결제 부분 취소 (잔액 남음) 잔여 금액 갱신, 부분 환불 내역 기록 필수
5 PAYMENT_VIRTUAL_ACCOUNT_ISSUED 가상계좌발급완료 (입금 대기) 입금 안내 표시 권장
status로는 부분취소를 아예 포착할 수 없습니다

부분취소는 잔액이 남아 있어 결제 상태가 유지됩니다. 그래서 PAYMENT_PARTIAL_CANCELLED 웹훅은 status: 1(결제완료) 로 도착하고, status: 20은 잔액이 0이 되는 전액 취소에서만 나옵니다.

즉 status fallback 경로에서는 부분취소를 잘못 분기하는 정도가 아니라 발생 사실 자체를 놓칩니다 — 결제완료 이벤트와 구분이 되지 않기 때문입니다. 부분취소를 처리해야 한다면 반드시 webhook_type으로 분기​​하거나, 조회 응답의 cancelled_price > 0 && price > 0 조건으로 판정합니다.

오류 웹훅 처리

관리자 → 웹훅 설정에서 "오류 웹훅 수신"을 활성화하면 다음 이벤트도 함께 수신합니다. 오류 웹훅에는 별도 status 코드가 없으므로 webhook_type으로만 식별할 수 있습니다.

webhook_type 발생 시점 권장 처리
PAYMENT_CONFIRM_FAILED 결제 승인 단계에서 PG 거절·통신 오류 주문을 failed 처리, 사용자에게 재시도 안내
PAYMENT_CANCEL_FAILED 취소 요청 실패 취소 상태를 되돌리고 운영팀 알림, 수동 취소 큐로 이동
PAYMENT_REQUEST_FAILED 결제창 호출 단계에서 실패 주문을 정리(또는 보류), 재시도 가능 여부를 사용자에게 안내
ERROR 위 케이스에 해당하지 않는 기타 오류 로그·Sentry 등에 캡처 후 운영팀이 직접 확인
오류 페이로드 차이

오류 웹훅 본문은 { error_code, pg_error_code, message, payload, webhook_type } 구조입니다. 정상 페이로드와 달리 order_id·receipt_id·status가 최상위에 없고 payload 안​​에 들어오므로, data['order_id']처럼 최상위에서 읽으면 값을 얻지 못합니다.

payload에는 PG 연동 정보가 있으면 PG 원본 응답이, 없으면 최소한의 영수증 정보가 담깁니다. error_code·pg_error_code·message와 함께 통째로 로깅합니다.

결제 웹훅 처리 코드

app.post('/webhook/bootpay', async (req, res) => {
    const { receipt_id, status, price, order_id, webhook_type } = req.body

    // 1. 오류 웹훅은 별도 status가 없으므로 webhook_type만으로 분기
    //    오류 본문은 { error_code, pg_error_code, message, payload, webhook_type } 구조라
    //    order_id / receipt_id / status는 최상위가 아니라 payload 안에 들어옵니다
    if (webhook_type && (webhook_type.endsWith('_FAILED') || webhook_type === 'ERROR')) {
        const { payload = {}, error_code, pg_error_code, message } = req.body
        await db.orders.update(
            {
                status: 'failed',
                failure_reason: error_code || webhook_type,
                pg_error_code,
                failure_message: message,
                failed_at: new Date()
            },
            { where: { order_id: payload.order_id } }
        )
        return res.status(200).json({ success: true })
    }

    // 2. 결제 조회 API로 검증 (위변조 방지)
    const receipt = await Bootpay.receiptPayment(receipt_id)

    if (receipt.status !== status || receipt.price !== price) {
        return res.status(200).json({ success: true })
    }

    // 3. webhook_type 우선, 없으면 status로 fallback
    switch (webhook_type) {
        case 'PAYMENT_COMPLETED': // 결제 완료
            await db.orders.update({
                bootpay_receipt_id: receipt_id,
                amount: price,
                method: receipt.method,
                status: 'done',
                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 'PAYMENT_CANCELLED': // 전체 취소 — 주문 전체를 환불 처리
            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 'PAYMENT_PARTIAL_CANCELLED': // 부분 취소 — 잔여 금액만 갱신, 주문은 유지
            await db.orders.update(
                {
                    status: 'partially_refunded',
                    // 잔여 금액은 별도 필드가 아니라 receipt.price 자체입니다 (원금 − 누적취소)
                    remaining_amount: receipt.price,
                    cancelled_amount: receipt.cancelled_price
                },
                { where: { bootpay_receipt_id: receipt_id } }
            )
            await db.refundLogs.create({
                bootpay_receipt_id: receipt_id,
                amount: receipt.cancelled_price,
                refunded_at: new Date()
            })
            break

        case 'PAYMENT_VIRTUAL_ACCOUNT_ISSUED': // 가상계좌 발급 (입금 대기)
            await db.orders.update(
                { status: 'pending', bootpay_receipt_id: receipt_id },
                { where: { order_id } }
            )
            break

        default:
            // 구버전 SDK 대비 — webhook_type이 없으면 status 기반으로 처리
            if (status === 1) {/* 결제 완료 */}
            else if (status === 20) {/* 취소 (부분/전체 구분 불가) */}
            else if (status === 5) {/* 가상계좌 발급 */}
    }

    res.status(200).json({ success: true })
})javascript
반드시 결제 조회 API로 검증합니다

결제 웹훅의 receipt_id를 사용해 Bootpay 서버에서 직접 영수증을 조회하여, 금액(price)과 상태(status)가 일치하는지 확인해야 합니다.

가상계좌 흐름

가상계좌는 웹훅이 두 번 올 수 있습니다: 발급 시(status: 5, webhook_type: PAYMENT_VIRTUAL_ACCOUNT_ISSUED) → 입금 완료 시(status: 1, webhook_type: PAYMENT_COMPLETED). 두 이벤트를 모두 처리해야 합니다.

webhook_type 처리 권장 전략

  1. webhook_type을 1차 분기 기준으로 사용합니다. API 버전이 4.10.0 초과(베타 제외) 또는 5.1.6 초과이면 항상 채워져 들어옵니다. status만으로는 부분취소를 포착할 수 없습니다.
  2. default(fallback)로 status 분기를 남겨 둡니다. 낮은 API 버전·미정의 이벤트 대비용입니다. 단, 부분취소는 status: 1로 도착하므로 이 경로에서는 결제완료와 구분되지 않아 부분취소 발생 자체를 포착할 수 없다​​는 점을 인지해야 합니다.
  3. 오류 웹훅(*_FAILED, ERROR)은 결제 조회 API 검증 전에 분기합니다. 오류 페이로드는 정상 영수증 조회가 안 될 수 있으므로 webhook_type만으로 처리하고 success: true를 빠르게 반환해야 재시도 폭주를 막을 수 있습니다.
  4. webhook_type을 로깅 키로 사용합니다. 운영 모니터링·알림에서 이벤트별 수신량을 집계합니다.

디버깅: 웹훅이 안 올 때

# 확인 사항 해결 방법
1 웹훅 URL이 HTTPS인가? HTTP도 등록은 되지만 운영에서는 HTTPS를 사용합니다. SSL 인증서가 유효한지 확인
2 관리자에서 이벤트를 선택했는가? 관리자 → 웹훅 설정에서 필요한 이벤트 체크
3 응답 본문에 {"success": true}를 담았는가? 이 값이 없으면 HTTP 200이어도 실패로 기록되고 재시도됨
4 방화벽이 차단하지 않는가? Bootpay 발신 IP 대역 223.130.82.0/24 허용
5 로컬 개발 환경인가? ngrok 등 터널링 도구 사용
6 테스트 웹훅을 발송해봤는가? 웹훅 설정에서 테스트 웹훅 발송 기능 사용
7 샌드박스 모드인가? 샌드박스에서도 웹훅은 정상 발송됨

로컬 개발 시 웹훅 받기

로컬 개발 환경에서는 외부에서 접근할 수 없으므로 터널링 도구를 사용합니다.

# ngrok 사용
ngrok http 3000
# → https://abc123.ngrok.io 주소를 웹훅 URL로 등록

# 또는 localtunnel
npx localtunnel --port 3000bash

터널링 URL을 Bootpay 관리자 웹훅 설정에 등록하면 로컬에서도 웹훅을 수신할 수 있습니다.

함께 보기