서버 연동

웹훅

이벤트 수신부터 재시도 대응까지 서버 분기를 정리해요.

핵심 요약

  • 웹훅은 결제 완료, 취소, 가상계좌 발급 같은 상태 변화를 서버로 즉시 전달해요.
  • 웹훅 URL 등록만으로 끝나지 않고, 수신 엔드포인트에서 이벤트 타입별 비즈니스 로직을 분기해야 해요.
  • 결제 웹훅은 receipt_id 로 결제 조회 API를 다시 호출해 금액과 상태를 검증해야 해요.
  • 멱등성과 빠른 200 응답을 기준으로 설계해야 재시도와 중복 처리 문제를 줄일 수 있어요.

이 문서는 결제 완료, 취소, 가상계좌 입금 같은 상태 변경을 서버에서 받는 방법을 설명해요. 아래 순서대로 웹훅 URL을 등록하고, 수신 엔드포인트에서 이벤트별 주문 상태를 처리해야 해요.

주문·구독 웹훅은 별도

주문·구독 이벤트 웹훅은 커머스 SDK 쪽에서 다뤄요 → 웹훅

1웹훅 설정

설정 순서

  • ① 웹훅 URL 등록 -> ② 이벤트 처리 코드 구현

웹훅 URL 등록

부트페이 관리자 > 개발자 설정 > 웹훅 설정에서 웹훅을 수신할 HTTPS 엔드포인트를 등록해야 해요.

::: warning 웹훅 URL은 반드시 HTTPS​​여야 해요. HTTP URL은 보안상 지원하지 않아요.

이벤트 처리 코드 구현

모든 이벤트가 등록한 URL로 수신돼요. 서버에서 이벤트 타입별로 분기 처리해요. :::

결제 웹훅

이벤트 목록

status 설명 가맹점이 할 일
1 결제완료 주문 상태 업데이트​, 서비스 활성화
20 결제취소완료 취소 상태 반영​, 서비스 비활성화
5 가상계좌발급완료 (입금 대기) 입금 안내 표시
가상계좌 흐름

가상계좌는 웹훅이 두 번 올 수 있어요: 발급 시(status: 5) → 입금 완료 시(status: 1).

페이로드 구조

{
  "receipt_id": "6721abc123def456...",
  "order_id": "your_order_id",
  "price": 1000,
  "tax_free": 0,
  "order_name": "테스트 상품",
  "pg": "kcp",
  "method": "card",
  "method_symbol": "card",
  "status": 1,
  "status_locale": "결제완료",
  "webhook_type": "PAYMENT_COMPLETED",
  "purchased_at": "2024-01-01T12:00:00+09:00",
  "card_data": {
    "card_approve_no": "12345678",
    "card_no": "1234-****-****-5678",
    "card_company": "신한카드"
  }
}json
필드 타입 설명
receipt_id String Bootpay 영수증 ID (검증·취소용)
order_id String 가맹점 주문번호
price Number 결제 금액
tax_free Number 비과세 금액
status Number 1: 결제완료, 5: 가상계좌발급완료, 20: 결제취소완료
webhook_type String 이벤트 식별자. status보다 세분화된 분기에 사용 (SDK 5.x.x 이상에서 전송)
method String 결제수단 (card, bank, phone 등)
purchased_at String 결제 완료 시각

webhook_type 값

status만으로는 부분취소·전체취소를 구분할 수 없거나 오류 종류를 식별할 수 없어요. SDK 5.x.x 버전부터 함께 전송되는 webhook_type으로 이벤트를 정확히 분기해요.

정상 이벤트

webhook_type 설명 대응 status
PAYMENT_COMPLETED 결제 완료 1
PAYMENT_VIRTUAL_ACCOUNT_ISSUED 가상계좌 발급 완료 (입금 대기) 5
PAYMENT_CANCELLED 결제 전체 취소 완료 20
PAYMENT_PARTIAL_CANCELLED 결제 부분 취소 완료 20

오류 이벤트 (관리자 → 웹훅 설정에서 오류 웹훅 수신을 활성화한 경우에만 발송)

webhook_type 설명
PAYMENT_CONFIRM_FAILED 결제 승인 실패
PAYMENT_CANCEL_FAILED 결제 취소 실패
PAYMENT_REQUEST_FAILED 결제 요청 실패
ERROR 그 외 분류되지 않은 오류
왜 webhook_type이 필요한가
  • 전체 취소(PAYMENT_CANCELLED)와 부분 취소(PAYMENT_PARTIAL_CANCELLED)는 둘 다 status: 20이라 status만 보고는 구분할 수 없어요. 부분 취소 시 잔여 금액·재고를 다르게 처리하려면 webhook_type으로 분기해야 해요.
  • 오류 웹훅은 별도 status 코드를 가지지 않아 webhook_type이 유일한 식별 수단이에요.

수신 코드

순서 발신 수신 내용
1 BOOTPAY 웹훅 서버 웹훅 전송 / receipt_id / status / price
2 웹훅 서버 결제 조회 API receipt_id로 검증 조회
3 결제 조회 API 웹훅 서버 실제 status / price 반환
4 웹훅 서버 웹훅 서버 위변조 여부 확인
5 웹훅 서버 주문 DB 상태별 주문 업데이트
6 웹훅 서버 BOOTPAY HTTP 200 응답
app.post('/webhook/bootpay', async (req, res) => {
    const { receipt_id, status, price, order_id, webhook_type } = req.body

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

    if (receipt.status !== status || receipt.price !== price) {
        return res.status(200).json({ status: 200 }) // 200은 반환하되 처리 안 함
    }

    // 2. webhook_type으로 분기 (status보다 정확함, SDK 5.x.x 이상)
    switch (webhook_type) {
        case 'PAYMENT_COMPLETED': // 결제 완료
            await db.orders.update({
                bootpay_receipt_id: receipt_id,
                amount: price,
                status: 'done',
                paid_at: new Date()
            }, { where: { order_id } })
            break
        case 'PAYMENT_CANCELLED': // 전체 취소
            await db.orders.update(
                { status: 'refunded' },
                { where: { bootpay_receipt_id: receipt_id } }
            )
            break
        case 'PAYMENT_PARTIAL_CANCELLED': // 부분 취소 — 잔여 금액만큼 별도 처리
            await db.orders.update(
                { status: 'partially_refunded', remaining_amount: receipt.remain_price },
                { where: { bootpay_receipt_id: receipt_id } }
            )
            break
        case 'PAYMENT_VIRTUAL_ACCOUNT_ISSUED': // 가상계좌 발급 (입금 대기)
            await db.orders.update(
                { status: 'pending', bootpay_receipt_id: receipt_id },
                { where: { order_id } }
            )
            break
        default:
            // webhook_type이 없는 구버전 SDK 또는 미확정 이벤트 — status 기반으로 fallback
            if (status === 1) {/* 결제 완료 처리 */}
            else if (status === 20) {/* 취소 처리 */}
            else if (status === 5) {/* 가상계좌 발급 처리 */}
    }

    res.status(200).json({ status: 200 })
})javascript
반드시 결제 조회 API로 검증해요

웹훅 데이터는 위변조될 수 있어요. receipt_id결제 조회 API를 호출하여 금액과 상태를 반드시 검증한 후 처리해요.

재시도 정책

웹훅 수신 실패 시 지수 백오프로 자동 재시도해요.

재시도 간격
1회 1분 후
2회 5분 후
3회 30분 후
4회 2시간 후
5회 24시간 후

공통 주의사항

  • HTTPS 필수​: 웹훅 URL은 반드시 HTTPS여야 한다
  • 빠른 응답​: 200 응답을 먼저 반환하고, 비즈니스 로직은 비동기로 처리해요
  • 멱등성​: 동일 이벤트가 여러 번 올 수 있으므로 중복 처리를 방지해요

웹훅 수신 서버에서 비즈니스 로직 처리 중 에러가 발생해도, 반드시 HTTP 200을 먼저 반환해야 해요. 그렇지 않으면 불필요한 재시도가 발생해요.

에러 코드

공통 에러

인증·권한 관련 에러는 에러 코드표를 참고해요.

코드 메시지 대처 방법
WEBHOOK_LOG_NOT_FOUND 웹훅 로그 정보가 없다. webhook_log_id를 확인해요
WEBHOOK_LOG_URL_BLANK 웹훅 로그 URL이 설정되지 않았습니다. 웹훅 URL을 설정해요
WEBHOOK_URL_BLANK 웹훅 URL을 입력한다 url 파라미터를 입력해요
WEBHOOK_HEADER_CONTENT_TYPE_INVALID 웹훅 Content Type 선택이 잘못되었습니다. 다시 확인한다. application/json 또는 application/x-www-form-urlencoded를 사용해요
WEBHOOK_RETRY_COUNT_INVALID 웹훅 재시도는 최소 1회부터 최대 25회까지 설정이 가능한다 1~25 사이 값을 설정해요

로컬에서 테스트

# ngrok으로 로컬 서버를 외부에 노출
npx ngrok http 3000
# → https://abc123.ngrok.io 주소를 웹훅 URL로 등록bash
더 자세한 웹훅 처리 가이드

이벤트별 코드 예시, 멱등성 보장 패턴, 디버깅 체크리스트는 웹훅 처리 가이드를 참고해요.

2이벤트 가이드

"웹훅 받으면 뭘 해야 해?" — 이벤트별로 가맹점이 수행해야 하는 비즈니스 로직을 정리해요.

빠르게 시작하려면?

웹훅 빠른 시작 — 최소 코드로 시작하세요. 이 페이지는 전체 이벤트와 고급 패턴(멱등성, DB 설계)을 다루는 전체 레퍼런스 문서예요.

웹훅 설정에서 URL 등록과 기본 코드를 확인하세요. 이 문서는 설정 이후​, 각 이벤트를 받았을 때 실제로 무엇을 해야 하는지​​에 집중해요.

웹훅 응답 필수 조건 — 이 두 가지를 모두 충족해야 "처리 완료"로 인식된다
# 조건 예시
1 HTTP 상태 코드 200 반환 res.status(200)
2 JSON 응답 본문에 { "success": true } 포함 res.json({ success: true })

두 조건 중 하나라도 빠지면 Bootpay는 웹훅이 실패한 것으로 판단하고 재시도​​해요.

결제 웹훅

상태별 처리

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

전체취소(PAYMENT_CANCELLED)와 부분취소(PAYMENT_PARTIAL_CANCELLED)는 모두 status: 20으로 도착해요. 부분취소 시 주문을 통째로 환불 처리하면 잔여 결제 금액·재고가 어긋날 수 있으므로 webhook_type을 기준으로 분기​​해야 해요.

오류 웹훅 처리

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

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

오류 웹훅은 정상 페이로드와 달리 payload 필드 안에 PG 원본 응답이 함께 들어와요. 가능한 한 payload의 원인 메시지(에러 코드·사유)를 함께 로깅해야 운영 디버깅 시간을 줄일 수 있어요.

결제 웹훅 처리 코드

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

    // 1. 오류 웹훅은 별도 status가 없으므로 webhook_type만으로 분기
    if (webhook_type && webhook_type.endsWith('_FAILED')) {
        await db.orders.update(
            { status: 'failed', failure_reason: webhook_type, failed_at: new Date() },
            { where: { 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',
                    remaining_amount: receipt.remain_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차 분기 기준으로 사용해요. SDK 5.x.x 이상에서는 항상 채워져 들어와요. status만으로 부분취소/전체취소를 구분할 수 없어요.
  2. default(fallback)로 status 분기를 남겨두세요. 구버전 SDK·미정의 이벤트 대비용이에요. 단, fallback 경로에서는 부분취소를 식별할 수 없다는 점을 인지해야 해요.
  3. 오류 웹훅(*_FAILED, ERROR)은 결제 조회 API 검증 전에 분기해요. 오류 페이로드는 정상 영수증 조회가 안 될 수 있으므로 webhook_type만으로 처리하고 success: true를 빠르게 반환해야 재시도 폭주를 막을 수 있어요.
  4. webhook_type을 로깅 키로 사용하세요. 운영 모니터링·알림에서 어떤 이벤트가 얼마나 들어왔는지 집계할 때 유용해요.

디버깅: 웹훅이 안 올 때

# 확인 사항 해결 방법
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 관리자 웹훅 설정에 등록하면 로컬에서도 웹훅을 수신할 수 있어요.