서버 연동

웹훅

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

핵심 요약

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

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

주문·구독 웹훅은 별도

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

1웹훅 설정

설정 순서

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

웹훅 URL 등록

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

::: warning 웹훅 URL은 HTTPS를 권장​​해요. URL 형식 검증은 http도 통과시키지만, 결제 정보가 평문으로 오가므로 운영 환경에서는 HTTPS를 사용해요.

같은 화면에서 Content-Type​​도 함께 고를 수 있어요. application/json이면 JSON 본문으로, application/x-www-form-urlencoded로 설정하면 폼 인코딩 본문으로 전송돼요. 수신 서버의 파싱 방식을 설정값과 맞춰야 해요.

이벤트 처리 코드 구현

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

결제 웹훅

이벤트 목록

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

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

페이로드 구조

{
  "receipt_id": "6721abc123def456...",
  "order_id": "your_order_id",
  "price": 1000,
  "tax_free": 0,
  "order_name": "테스트 상품",
  "cancelled_price": 0,
  "cancelled_tax_free": 0,
  "pg": "kcp",
  "method": "카드",
  "method_symbol": "card",
  "method_origin": "카드",
  "method_origin_symbol": "card",
  "currency": "KRW",
  "status": 1,
  "status_locale": "결제완료",
  "webhook_type": "PAYMENT_COMPLETED",
  "application_id": "5b8f6a048d5cd63a4d9a8bd1",
  "sandbox": false,
  "metadata": {},
  "purchased_at": "2024-01-01T12:00:00+09:00",
  "requested_at": "2024-01-01T11:59:40+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 잔여 비과세 금액
cancelled_price Number 누적 취소 금액
cancelled_tax_free Number 누적 취소 면세 금액
status Number 1: 결제완료, 5: 가상계좌발급완료, 20: 결제취소완료(전액)
status_locale String 상태 한글 표기
webhook_type String 이벤트 식별자. status보다 세분화된 분기에 사용 (API 버전 조건은 아래 참고)
application_id String 결제를 요청한 연동키의 Application ID. 웹훅 본문에만 담기는 필드​​로, 여러 프로젝트를 한 URL로 받을 때 구분에 써요
billing_key String 빌링키로 발생한 결제 건일 때만 담기는 웹훅 전용 필드
method String 결제수단 한글명 (카드, 계좌이체, 휴대폰 등)
method_symbol String 결제수단 영문 심볼 (card, bank, phone 등)
method_origin / method_origin_symbol String 간편결제 내부에서 실제 사용된 원 결제수단의 한글명 / 영문 심볼
currency String 통화 (KRW 등)
metadata Object 결제 요청 시 넣은 메타데이터
sandbox Boolean 샌드박스 결제 여부
purchased_at String 결제 완료 시각
requested_at String 결제 요청 시각
cancel_id String 이번 취소 요청의 고유 ID (API 버전 2.5 이상)
last_cancelled_at String 마지막 취소 시각 (API 버전 2.5 이상)
escrow_data Object 에스크로 결제일 때만 담기는 배송·구매확정 상태 정보
값이 없는 필드는 빠져요

웹훅 본문은 값이 비어 있는 필드를 제거한 뒤 전송돼요. 필드 존재를 전제로 파싱하면 안 돼요.

webhook_type 값

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

webhook_type 전송 조건

webhook_type은 결제를 요청한 API 버전이 4.10.0 초과(베타 버전 제외)이거나 5.1.6 초과​​일 때 함께 전송돼요. 그보다 낮은 버전으로 요청한 결제 건의 웹훅에는 이 필드가 없어요.

정상 이벤트

webhook_type 설명 대응 status
PAYMENT_COMPLETED 결제 완료 1
PAYMENT_VIRTUAL_ACCOUNT_ISSUED 가상계좌 발급 완료 (입금 대기) 5
PAYMENT_CANCELLED 결제 전체 취소 완료 (잔액 0) 20
PAYMENT_PARTIAL_CANCELLED 결제 부분 취소 완료 (잔액 남음) 1
PAYMENT_EXPIRED 결제 만료 (common_event_webhook 설정 시) 만료 상태
AUTHENTICATION_COMPLETED 본인인증 완료 인증 완료 상태
ESCROW_STATUS_CHANGED 에스크로 상태 변경 (배송 시작·구매확정·거절 등) 결제 상태 유지

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

webhook_type 설명
PAYMENT_CONFIRM_FAILED 결제 승인 실패
PAYMENT_CANCEL_FAILED 결제 취소 실패
PAYMENT_REQUEST_FAILED 결제 요청 실패
ERROR 그 외 분류되지 않은 오류
오류 웹훅은 본문 구조가 달라요

오류 웹훅 본문은 정상 페이로드와 구조가 다릅니다. 최상위에는 error_code, pg_error_code, message, payload, webhook_type만 있고, order_id·receipt_id·status는 최상위가 아니라 payload 안​​에 들어와요. 정상 페이로드처럼 data['order_id']로 읽으면 값이 없어요.

{
  "error_code": "RC_CANCEL_SERVER_ERROR",
  "pg_error_code": "8104",
  "message": "취소 가능 기간이 지나 취소할 수 없습니다.",
  "webhook_type": "PAYMENT_CANCEL_FAILED",
  "payload": {
    "receipt_id": "6721abc123def456...",
    "order_id": "your_order_id",
    "price": 1000,
    "status": 1
  }
}json

payload는 PG 연동 정보가 있으면 PG 원본 응답이, 없으면 최소한의 영수증 정보가 담겨요. PG마다 구조가 다르므로 고정 스키마를 가정하지 말고 통째로 로깅해요.

왜 webhook_type이 필요한가
  • 부분 취소는 잔액이 남아 있어 결제 상태가 유지돼요. 즉 PAYMENT_PARTIAL_CANCELLED 웹훅은 status: 1 로 도착하고, status: 20은 전액 취소에서만 나와요. status만 보면 부분취소가 일어난 사실 자체를 놓쳐요.
  • 오류 웹훅은 별도 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({ success: true }) // 처리는 안 하지만 재시도를 막기 위해 success:true 반환
    }

    // 2. webhook_type으로 분기 (status보다 정확함, API 버전 4.10.0 초과 또는 5.1.6 초과)
    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(
                // 잔여 금액은 별도 필드가 아니라 receipt.price 자체예요 (원금 − 누적취소)
                { status: 'partially_refunded', remaining_amount: receipt.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({ success: true })
})javascript
반드시 결제 조회 API로 검증해요

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

재시도 정책

웹훅이 실패로 판정되면 자동으로 재시도해요.

항목
기본 재시도 횟수 10회
설정 가능 범위 관리자 웹훅 설정에서 1~25회
재시도 간격 작업 큐(Sidekiq)의 기본 지수 백오프를 따라 재시도할수록 간격이 늘어나요

고정된 5단계 스케줄(1분 → 5분 → …)이 아니에요. 정확한 재시도 시각을 전제로 로직을 짜지 말고, 같은 이벤트가 여러 번 도착해도 안전하도록 멱등하게 처리해요.

공통 주의사항

  • HTTPS 권장​: URL 형식 검증은 http도 통과하지만, 운영 환경에서는 HTTPS를 사용해요
  • Content-Type 확인​: 관리자 설정에 따라 application/json 또는 application/x-www-form-urlencoded로 전송되므로 수신 서버의 파싱 방식을 맞춰요
  • 빠른 응답​: {"success": true}를 먼저 반환하고, 무거운 비즈니스 로직은 비동기로 처리해요
  • 멱등성​: 동일 이벤트가 여러 번 올 수 있으므로 중복 처리를 방지해요

웹훅 수신 서버에서 비즈니스 로직 처리 중 에러가 발생해도, 응답 본문에 {"success": true}를 먼저 반환해야 해요. 성공 판정은 본문의 success 값만 보므로, 이 값을 주지 않으면 재시도가 계속돼요.

에러 코드

공통 에러

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

코드 메시지 대처 방법
APP_WEBHOOK_NOT_CONFIGURE (280) Webhook URL을 설정을 해주세요. 관리자 웹훅 설정에서 수신 URL을 먼저 등록해요
APP_WEBHOOK_RETRY_COUNT_OVER (281) Webhook 재시도 횟수를 25회 초과로 설정할 수 없습니다. 재시도 횟수를 1~25 사이로 설정해요
APP_KEY_FEEDBACK_URL_INVALID (1208) Webhook URL 이 URL 형식에 맞지 않습니다. 확인해주세요. https://... 형태의 온전한 URL로 입력해요
APP_KEY_CONTENT_TYPE_INVALID (1210) 형식에 맞지 않는 Content-Type 입니다. application/json 또는 application/x-www-form-urlencoded를 선택해요
RC_WEBHOOK_STATUS_INVALID (2070) 결제완료/취소시에만 webhook 재시도가 가능합니다. 결제완료·취소 상태의 건에 대해서만 웹훅 재전송을 요청해요

로컬에서 테스트

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

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

2이벤트 가이드

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

빠르게 시작하려면?

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

웹훅 설정에서 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 대역 허용
5 로컬 개발 환경인가? ngrok 등 터널링 도구 사용
6 테스트 웹훅을 발송해봤는가? 웹훅 설정에서 테스트 웹훅 발송 기능 사용
7 샌드박스 모드인가? 샌드박스에서도 웹훅은 정상 발송됨

로컬 개발 시 웹훅 받기

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

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

# 또는 localtunnel
npx localtunnel --port 3000bash

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