서버 연동

주문 웹훅 설정

URL 등록과 이벤트 처리 코드, 두 단계면 끝나요.

이럴 때 필요해요

결제/주문/구독 상태 변경을 서버에서 실시간 수신할 때예요.

내가 할 일: 서버(백엔드) — 웹훅 수신 엔드포인트 구현

설정 순서

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

웹훅 URL 등록

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

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

이벤트 처리 코드 구현

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

결제 웹훅

이벤트 목록 (status)

결제 웹훅의 status는 숫자​​예요 (커머스 API 의 주문 status 와 다른 값 체계).

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

가상계좌는 웹훅이 2번 온다: 발급 시(status: 2) → 입금 완료 시(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": "결제완료",
  "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: 완료, 2: 대기, 20: 취소
method String 결제수단 (card, bank, phone 등)
purchased_at String 결제 완료 시각

수신 코드

순서 발신 수신 내용
1 BOOTPAY 웹훅 서버 웹훅 전송 / receipt_id / status / price
2 웹훅 서버 결제 조회 API receipt_id로 검증 조회
3 결제 조회 API 웹훅 서버 실제 status / price 반환
3 웹훅 서버 웹훅 서버 위변조 여부 확인
4 웹훅 서버 주문 DB 상태별 주문 업데이트
5 웹훅 서버 BOOTPAY HTTP 200 응답
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({ status: 200 }) // 200은 반환하되 처리 안 함
    }

    // 2. 상태별 처리
    switch (status) {
        case 1: // 결제 완료
            await db.orders.update({
                bootpay_receipt_id: receipt_id,
                amount: price,
                status: 'paid',
                paid_at: new Date()
            }, { where: { order_id } })
            break
        case 20: // 결제 취소
            await db.orders.update(
                { status: 'refunded' },
                { where: { bootpay_receipt_id: receipt_id } }
            )
            break
    }

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

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

재시도 정책

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

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

커머스 웹훅

주문 이벤트

이벤트 설명 가맹점이 할 일
order.done 주문 결제 완료 재고 차감​, 서비스 활성화
order.cancelled 주문 전체 취소 재고 복구​, 서비스 비활성화
order.partial_cancelled 부분 취소 해당 상품만 재고 복구
order.transaction_pending 지연 결제 대기 (무통장 등) 입금 안내 표시
order.confirm_error 결제 승인 실패 에러 로그 기록, 주문 실패 처리
order.cancel_error 취소 처리 실패 재고를 되돌리지 말고 운영자에게 알려요
알려진 문제 — order.partial_cancelled 에 payload 가 없어요

지금 부분 취소 웹훅은 webhook_type 만 담겨 오고 payload·idempotency_key 가 없어요. 이 이벤트를 받으면 취소 요청 목록이나 주문 상세(cancelled_price)를 다시 조회해 반영해요. 중복 판단은 idempotency_key 대신 조회 결과로 해요.

알려진 문제 — order.transaction_pending 은 지금 발송되지 않아요

지연 결제 대기 이벤트는 정의만 있고 실제로 나가지 않아요. 입금 대기 상태는 주문 상세의 status(payment_pending)·receipt_status(receipt_issued)로 확인해요.

구독 이벤트

이벤트 설명 가맹점이 할 일
subscription.requested_done 구독 신청 접수 관리자에게 승인 요청 알림
subscription.requested_error 구독 신청 오류 신청 실패 처리
subscription.approved 구독 승인 서비스 활성화 (service_active = true)
subscription.rejected 구독 거절 신청 거절 안내
subscription.renewed 회차 결제 성공 (갱신) 서비스 기간 연장, 다음 결제일 업데이트
subscription.renewal_upcoming 갱신 예정 알림 사전 안내
subscription.modified 구독 내용 변경 변경 내용 반영
subscription.upgraded 플랜 업그레이드 상위 권한 반영
subscription.downgraded 플랜 다운그레이드 하위 권한 반영
subscription.paused 구독 일시정지 (관리자) 서비스 일시 제한
subscription.resumed 구독 재개 서비스 재활성화
subscription.hold_on 회차 결제 실패로 자동 중단 서비스 제한 + 복구 결제 안내
subscription.terminated 결제 실패 반복으로 자동 해지 서비스 비활성화 (service_active = false)
subscription.admin_terminated 구독 해지 (관리자) 서비스 비활성화 + 고객 안내
subscription.expired 약정 만료 서비스 비활성화
subscription.restored 관리자가 해지를 되돌림 서비스 재활성화
subscription.expiring_soon 만료 예정 알림 (7일 전) 연장 안내
subscription.transfer_requested 중도인수 요청 승인 흐름 시작
subscription.transfer_approved 중도인수 승인 구독자 교체 반영
subscription.trial_started 체험 시작 서비스 활성화 (제한적)
subscription.trial_ending 체험 종료 예정 (3일 전) 전환 안내
subscription.trial_ended 체험 종료·정식 전환 정식 권한으로 전환

subscription.renewed · renewal_upcoming · expired · expiring_soon · transfer_requested · transfer_approved · trial_started · trial_ending · trial_ended 는 지금 발송되지 않고, 고객이 요청한 해지·일시정지에도 웹훅이 없어요. 현재 동작과 대신 확인하는 방법은 웹훅 처리 가이드의 알려진 문제에 모아 뒀어요.

구독 쿠폰·알림톡 이벤트는 별도예요

subscription_coupon.applied · .removed · .expired 도 같은 URL 로 와요. 알림톡 발송 결과(alimtalk.send.* · alimtalk.template.* · alimtalk.optout.*)는 완전히 별개의 웹훅​​이라 알림톡 웹훅 설정에서 따로 등록해요.

페이로드 구조

`payload.status` 는 결제 웹훅의 `status` 와 다른 값이에요

위 결제 웹훅의 status 는 숫자(1·20·2)지만, 커머스 웹훅 payload.status 는 주문 상태 문자열​​이에요 — payment_completed(결제 완료)·cancellation_completed(취소 완료) 등. 전체 값은 주문 상태를 봐요.

{
  "project_key": "your-project-uuid",
  "idempotency_key": "unique-key-for-deduplication",
  "webhook_type": "order.done",
  "payload": {
    "order_number": "25071182085082524116",
    "request_id": "your-order-draft-id",
    "price": 29900,
    "status": "payment_completed",
    "purchased_at": "2024-01-01T12:00:00+09:00",
    "products": [...],
    "user": { "user_id": "...", "name": "..." }
  }
}json
필드 타입 설명
project_key String 프로젝트 식별자
idempotency_key String 중복 처리 방지를 위한 고유 키
webhook_type String 이벤트 타입 (order.done, subscription.approved 등)
payload Object 이벤트별 데이터 — 주문 이벤트는 주문 상세 응답에서 order_purchases 를 뺀 모양이에요. order_id 는 없으니 order_number 로 맞춰요. 구독 이벤트는 구독 계약 모양(order_subscription_id 등)이에요
error Object 오류 이벤트에만 실려요 (order.confirm_error · order.cancel_error · subscription.requested_error)
diff Object 구독 이벤트에만 실려요. 직전 상태와 달라진 필드만 담겨요
payload.restore Object subscription.restored 에만 실려요. 되살아난 회차와 다음 결제일이 담겨요 (처리 가이드)
`subscription.approved` 는 payload 모양이 두 가지예요

구독 신청 결제와 함께 바로 승인되면 주문 모양 payload 로 오고 구독 ID 는 payload.order_subscriptions[].order_subscription_id 에 있어요. 관리자가 나중에 승인하면 구독 모양 payload 로 오고 payload.order_subscription_id 에 있어요. subscription.requested_done 도 신청 결제 때는 주문 모양이에요. 아래 예제처럼 두 경로를 함께 읽어요.

대문자 `webhook_type` 이 오면 구형 모양이에요

일부 경로는 봉투 없이 주문 필드가 그대로 펼쳐진 본문을 보내고, 그때 webhook_type 이 ORDER_COMPLETED · ORDER_CANCELLED · ORDER_PARTIAL_CANCELLED · ORDER_VIRTUAL_ACCOUNT_ISSUED 처럼 대문자​​로 와요. 수신 코드는 body.payload ?? body 로 두 모양을 함께 받도록 짜 두면 안전해요.

수신 코드

app.post('/webhook/commerce', async (req, res) => {
    const { idempotency_key, webhook_type, 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: webhook_type, status: 'received' })

    // 3. 즉시 200 반환
    res.status(200).json({ success: true })

    // 4. 비동기 처리
    switch (webhook_type) {
        case 'order.done':
            await db.orders.update(
                { status: 'paid', paid_at: new Date() },
                { where: { bootpay_order_number: payload.order_number } }
            )
            break
        case 'order.cancelled':
            await db.orders.update(
                { status: 'refunded' },
                { where: { bootpay_order_number: payload.order_number } }
            )
            break
        case 'subscription.approved': {
            // 신청 결제와 함께 승인되면 주문 모양, 관리자 승인이면 구독 모양으로 와요
            const ids = payload.order_subscription_id
                ? [payload.order_subscription_id]
                : (payload.order_subscriptions ?? []).map(s => s.order_subscription_id)
            await db.subscriptions.update(
                { service_active: true, status: 'active' },
                { where: { bootpay_subscription_id: ids } }
            )
            break
        }
        case 'subscription.terminated':
        case 'subscription.admin_terminated':
            await db.subscriptions.update(
                { service_active: false, status: 'terminated' },
                { where: { bootpay_subscription_id: payload.order_subscription_id } }
            )
            break
    }
})javascript

재시도 정책

설정 값
응답 제한 시간 30초
최대 재시도 기본 10회, 프로젝트 설정으로 최대 25회
재시도 조건 HTTP 200 이 아니거나, 응답 본문에 success: true 가 없거나, 타임아웃
200 만으로는 성공이 아니에요

커머스 웹훅은 HTTP 200 과 응답 본문 { "success": true } 를 모두 받아야 성공으로 기록해요. 200 만 돌려주고 본문을 비우면 계속 재시도돼요. (알림톡 웹훅만 2xx 로 충분해요)

공통 주의사항

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

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

에러 코드

공통 에러

인증·권한 관련 에러는 v1 인증 오류를 참고해요.

코드 메시지 대처 방법
WEBHOOK_URL_BLANK 웹훅 URL을 입력해요 웹훅 테스트 발송에 webhook_url 을 넘기거나 프로젝트에 웹훅을 등록해요
TEST_WEBHOOK_ORDER_NOT_FOUND 테스트용 주문을 찾을 수 없어요 부트페이에 문의해요

웹훅 URL·재시도 횟수 설정은 관리자 화면에서 해요. 커머스 API 로 돌아오는 웹훅 관련 에러는 위 두 가지뿐이에요.

로컬에서 테스트

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

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