결제/주문/구독 상태 변경을 서버에서 실시간 수신할 때예요.
내가 할 일: 서버(백엔드) — 웹훅 수신 엔드포인트 구현
설정 순서
- ① 웹훅 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@app.route('/webhook/bootpay', methods=['POST'])
def webhook():
data = request.get_json()
receipt_id = data['receipt_id']
status = data['status']
price = data['price']
order_id = data['order_id']
# 1. 결제 조회 API로 검증
bootpay = BootpayBackend(client_key, secret_key)
receipt = bootpay.get_receipt(receipt_id)
if receipt['status'] != status or receipt['price'] != price:
return jsonify({'status': 200}), 200
# 2. 상태별 처리
if status == 1: # 결제 완료
Order.update(order_id,
bootpay_receipt_id=receipt_id,
amount=price, status='paid')
elif status == 20: # 결제 취소
Order.update_by_receipt(receipt_id, status='refunded')
return jsonify({'status': 200}), 200python$data = json_decode(file_get_contents('php://input'), true);
$receiptId = $data['receipt_id'];
$status = $data['status'];
$price = $data['price'];
$orderId = $data['order_id'];
// 1. 결제 조회 API로 검증
$bootpay = new \Bootpay\BackendPhp();
$receipt = $bootpay->getReceipt($receiptId);
if ($receipt['status'] !== $status || $receipt['price'] !== $price) {
http_response_code(200);
echo json_encode(['status' => 200]);
exit;
}
// 2. 상태별 처리
if ($status === 1) {
Order::where('order_id', $orderId)->update([
'bootpay_receipt_id' => $receiptId,
'amount' => $price,
'status' => 'paid',
]);
} elseif ($status === 20) {
Order::where('bootpay_receipt_id', $receiptId)
->update(['status' => 'refunded']);
}
http_response_code(200);
echo json_encode(['status' => 200]);php웹훅 데이터는 위변조될 수 있어요. 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 |
취소 처리 실패 | 재고를 되돌리지 말고 운영자에게 알려요 |
지연 결제 대기 이벤트는 정의만 있고 실제로 나가지 않아요. 입금 대기 상태는 주문 상세의 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.*)는 완전히 별개의 웹훅이라 알림톡 웹훅 설정에서 따로 등록해요.
페이로드 구조
{
"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 에만 실려요. 되살아난 회차와 다음 결제일이 담겨요 (처리 가이드) |
구독 신청 결제와 함께 바로 승인되면 주문 모양 payload 로 오고 구독 ID 는 payload.order_subscriptions[].order_subscription_id 에 있어요. 관리자가 나중에 승인하면 구독 모양 payload 로 오고 payload.order_subscription_id 에 있어요. subscription.requested_done 도 신청 결제 때는 주문 모양이에요. 아래 예제처럼 두 경로를 함께 읽어요.
일부 경로는 봉투 없이 주문 필드가 그대로 펼쳐진 본문을 보내고, 그때 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@app.route('/webhook/commerce', methods=['POST'])
def commerce_webhook():
body = request.get_json()
idempotency_key = body['idempotency_key']
webhook_type = body['webhook_type']
payload = body['payload']
# 1. 중복 체크
if WebhookLog.query.filter_by(idempotency_key=idempotency_key).first():
return jsonify({'success': True}), 200
# 2. 로그 저장
WebhookLog.create(idempotency_key=idempotency_key, event=webhook_type)
# 3. 이벤트 처리
if webhook_type == 'order.done':
Order.update_by_number(payload['order_number'], status='paid')
elif webhook_type == 'order.cancelled':
Order.update_by_number(payload['order_number'], status='refunded')
elif webhook_type == 'subscription.approved':
# 신청 결제와 함께 승인되면 주문 모양, 관리자 승인이면 구독 모양으로 와요
ids = [payload['order_subscription_id']] if payload.get('order_subscription_id') \
else [s['order_subscription_id'] for s in payload.get('order_subscriptions', [])]
for sub_id in ids:
Subscription.activate(sub_id)
elif webhook_type in ('subscription.terminated', 'subscription.admin_terminated'):
Subscription.deactivate(payload['order_subscription_id'])
return jsonify({'success': True}), 200python$body = json_decode(file_get_contents('php://input'), true);
$idempotencyKey = $body['idempotency_key'];
$webhookType = $body['webhook_type'];
$payload = $body['payload'];
// 1. 중복 체크
if (WebhookLog::where('idempotency_key', $idempotencyKey)->exists()) {
http_response_code(200);
echo json_encode(['success' => true]);
exit;
}
// 2. 로그 저장
WebhookLog::create(['idempotency_key' => $idempotencyKey, 'event' => $webhookType]);
// 3. 이벤트 처리
switch ($webhookType) {
case 'order.done':
Order::where('bootpay_order_number', $payload['order_number'])
->update(['status' => 'paid']);
break;
case 'order.cancelled':
Order::where('bootpay_order_number', $payload['order_number'])
->update(['status' => 'refunded']);
break;
case 'subscription.approved':
// 신청 결제와 함께 승인되면 주문 모양, 관리자 승인이면 구독 모양으로 와요
$ids = isset($payload['order_subscription_id'])
? [$payload['order_subscription_id']]
: array_column($payload['order_subscriptions'] ?? [], 'order_subscription_id');
Subscription::whereIn('bootpay_subscription_id', $ids)
->update(['service_active' => true, 'status' => 'active']);
break;
case 'subscription.terminated':
case 'subscription.admin_terminated':
Subscription::where('bootpay_subscription_id', $payload['order_subscription_id'])
->update(['service_active' => false, 'status' => 'terminated']);
break;
}
http_response_code(200);
echo json_encode(['success' => true]);php재시도 정책
| 설정 | 값 |
|---|---|
| 응답 제한 시간 | 30초 |
| 최대 재시도 | 기본 10회, 프로젝트 설정으로 최대 25회 |
| 재시도 조건 | HTTP 200 이 아니거나, 응답 본문에 success: true 가 없거나, 타임아웃 |
커머스 웹훅은 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이벤트별 코드 예시, 멱등성 보장 패턴, 디버깅 체크리스트는 웹훅 처리 가이드를 참고해요.
