핵심 요약
- 웹훅은 결제 완료, 취소, 가상계좌 발급 같은 상태 변화를 서버로 즉시 전달해요.
- 웹훅 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 |
그 외 분류되지 않은 오류 |
- 전체 취소(
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@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']
webhook_type = data.get('webhook_type')
# 오류 웹훅은 검증 전에 처리
if webhook_type and (webhook_type.endswith('_FAILED') or webhook_type == 'ERROR'):
Order.mark_failed(order_id, reason=webhook_type)
return jsonify({'status': 200}), 200
# 1. 결제 조회 API로 검증
bootpay = BootpayBackend(client_key, secret_key)
receipt = bootpay.receipt_payment(receipt_id)
if receipt['status'] != status or receipt['price'] != price:
return jsonify({'status': 200}), 200
# 2. webhook_type 우선 분기 (SDK 5.x.x 이상)
if webhook_type == 'PAYMENT_COMPLETED':
Order.update(order_id,
bootpay_receipt_id=receipt_id,
amount=price, status='done')
elif webhook_type == 'PAYMENT_CANCELLED':
Order.update_by_receipt(receipt_id, status='refunded')
elif webhook_type == 'PAYMENT_PARTIAL_CANCELLED':
Order.update_by_receipt(receipt_id,
status='partially_refunded',
remaining_amount=receipt.get('remain_price'))
elif webhook_type == 'PAYMENT_VIRTUAL_ACCOUNT_ISSUED':
Order.update(order_id, bootpay_receipt_id=receipt_id, status='pending')
else:
# 구버전 SDK fallback
if status == 1: Order.update(order_id, status='done', amount=price)
elif status == 20: Order.update_by_receipt(receipt_id, status='refunded')
elif status == 5: Order.update(order_id, status='pending')
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'];
$webhookType = $data['webhook_type'] ?? null;
// 오류 웹훅은 검증 전에 처리
if ($webhookType && (str_ends_with($webhookType, '_FAILED') || $webhookType === 'ERROR')) {
Order::where('order_id', $orderId)->update(['status' => 'failed', 'failure_reason' => $webhookType]);
http_response_code(200);
echo json_encode(['status' => 200]);
exit;
}
// 1. 결제 조회 API로 검증
$receipt = BootpayApi::receiptPayment($receiptId);
if ($receipt['status'] !== $status || $receipt['price'] !== $price) {
http_response_code(200);
echo json_encode(['status' => 200]);
exit;
}
// 2. webhook_type 우선 분기
switch ($webhookType) {
case 'PAYMENT_COMPLETED':
Order::where('order_id', $orderId)->update([
'bootpay_receipt_id' => $receiptId,
'amount' => $price,
'status' => 'done',
]);
break;
case 'PAYMENT_CANCELLED':
Order::where('bootpay_receipt_id', $receiptId)->update(['status' => 'refunded']);
break;
case 'PAYMENT_PARTIAL_CANCELLED':
Order::where('bootpay_receipt_id', $receiptId)->update([
'status' => 'partially_refunded',
'remaining_amount' => $receipt['remain_price'] ?? 0,
]);
break;
case 'PAYMENT_VIRTUAL_ACCOUNT_ISSUED':
Order::where('order_id', $orderId)->update([
'bootpay_receipt_id' => $receiptId,
'status' => 'pending',
]);
break;
default: // 구버전 SDK fallback
if ($status === 1) {/* 결제 완료 */}
elseif ($status === 20) {/* 취소 */}
elseif ($status === 5) {/* 가상계좌 발급 */}
}
http_response_code(200);
echo json_encode(['status' => 200]);php@PostMapping("/webhook/bootpay")
public ResponseEntity<?> webhook(@RequestBody Map<String, Object> data) throws Exception {
String receiptId = (String) data.get("receipt_id");
int status = ((Number) data.get("status")).intValue();
double price = ((Number) data.get("price")).doubleValue();
String orderId = (String) data.get("order_id");
String webhookType = (String) data.get("webhook_type");
// 오류 웹훅은 검증 전에 처리
if (webhookType != null && (webhookType.endsWith("_FAILED") || webhookType.equals("ERROR"))) {
orderRepository.markFailed(orderId, webhookType);
return ResponseEntity.ok(Map.of("status", 200));
}
var receipt = bootpay.getReceipt(receiptId);
int actualStatus = ((Number) receipt.get("status")).intValue();
double actualPrice = ((Number) receipt.get("price")).doubleValue();
if (actualStatus != status || actualPrice != price) {
return ResponseEntity.ok(Map.of("status", 200));
}
// webhook_type 우선 분기
switch (webhookType == null ? "" : webhookType) {
case "PAYMENT_COMPLETED" -> orderRepository.markPaid(orderId, receiptId, price);
case "PAYMENT_CANCELLED" -> orderRepository.markRefundedByReceipt(receiptId);
case "PAYMENT_PARTIAL_CANCELLED" -> orderRepository.markPartiallyRefunded(
receiptId,
((Number) receipt.getOrDefault("remain_price", 0)).doubleValue()
);
case "PAYMENT_VIRTUAL_ACCOUNT_ISSUED" -> orderRepository.markPendingDeposit(orderId, receiptId);
default -> { // 구버전 SDK fallback
if (status == 1) orderRepository.markPaid(orderId, receiptId, price);
else if (status == 20) orderRepository.markRefundedByReceipt(receiptId);
else if (status == 5) orderRepository.markPendingDeposit(orderId, receiptId);
}
}
return ResponseEntity.ok(Map.of("status", 200));
}javapost '/webhook/bootpay' do
data = JSON.parse(request.body.read)
receipt_id = data['receipt_id']
status = data['status']
price = data['price']
order_id = data['order_id']
webhook_type = data['webhook_type']
# 오류 웹훅은 검증 전에 처리
if webhook_type && (webhook_type.end_with?('_FAILED') || webhook_type == 'ERROR')
Order.mark_failed(order_id, reason: webhook_type)
return { status: 200 }.to_json
end
receipt = bootpay.verify(receipt_id).data
unless receipt['status'] == status && receipt['price'] == price
return { status: 200 }.to_json
end
case webhook_type
when 'PAYMENT_COMPLETED'
Order.mark_paid(order_id, receipt_id: receipt_id, amount: price)
when 'PAYMENT_CANCELLED'
Order.mark_refunded_by_receipt(receipt_id)
when 'PAYMENT_PARTIAL_CANCELLED'
Order.mark_partially_refunded(receipt_id, remaining: receipt['remain_price'])
when 'PAYMENT_VIRTUAL_ACCOUNT_ISSUED'
Order.mark_pending_deposit(order_id, receipt_id)
else # 구버전 SDK fallback
case status
when 1 then Order.mark_paid(order_id, receipt_id: receipt_id, amount: price)
when 20 then Order.mark_refunded_by_receipt(receipt_id)
when 5 then Order.mark_pending_deposit(order_id, receipt_id)
end
end
{ status: 200 }.to_json
endrubyfunc bootpayWebhook(w http.ResponseWriter, r *http.Request) {
var data struct {
ReceiptID string `json:"receipt_id"`
Status int `json:"status"`
Price float64 `json:"price"`
OrderID string `json:"order_id"`
WebhookType string `json:"webhook_type"`
}
json.NewDecoder(r.Body).Decode(&data)
// 오류 웹훅은 검증 전에 처리
if strings.HasSuffix(data.WebhookType, "_FAILED") || data.WebhookType == "ERROR" {
markOrderFailed(data.OrderID, data.WebhookType)
json.NewEncoder(w).Encode(map[string]int{"status": 200})
return
}
receipt, err := api.GetReceipt(data.ReceiptID)
if err != nil {
json.NewEncoder(w).Encode(map[string]int{"status": 200})
return
}
status := int(receipt["status"].(float64))
price := receipt["price"].(float64)
if status != data.Status || price != data.Price {
json.NewEncoder(w).Encode(map[string]int{"status": 200})
return
}
switch data.WebhookType {
case "PAYMENT_COMPLETED":
markOrderPaid(data.OrderID, data.ReceiptID, data.Price)
case "PAYMENT_CANCELLED":
markOrderRefundedByReceipt(data.ReceiptID)
case "PAYMENT_PARTIAL_CANCELLED":
remain, _ := receipt["remain_price"].(float64)
markOrderPartiallyRefunded(data.ReceiptID, remain)
case "PAYMENT_VIRTUAL_ACCOUNT_ISSUED":
markOrderPendingDeposit(data.OrderID, data.ReceiptID)
default: // 구버전 SDK fallback
switch data.Status {
case 1:
markOrderPaid(data.OrderID, data.ReceiptID, data.Price)
case 20:
markOrderRefundedByReceipt(data.ReceiptID)
case 5:
markOrderPendingDeposit(data.OrderID, data.ReceiptID)
}
}
json.NewEncoder(w).Encode(map[string]int{"status": 200})
}go[HttpPost("/webhook/bootpay")]
public async Task<IActionResult> Webhook([FromBody] BootpayWebhook data)
{
// 오류 웹훅은 검증 전에 처리
if (data.WebhookType != null && (data.WebhookType.EndsWith("_FAILED") || data.WebhookType == "ERROR")) {
await orderRepository.MarkFailedAsync(data.OrderId, data.WebhookType);
return Ok(new { Status = 200 });
}
var response = await bootpay.GetReceipt(data.ReceiptId);
var body = await response.Content.ReadAsStringAsync();
var receipt = JsonConvert.DeserializeObject<Dictionary<string, object>>(body);
if (Convert.ToInt32(receipt["status"]) != data.Status ||
Convert.ToDouble(receipt["price"]) != data.Price) {
return Ok(new { Status = 200 });
}
switch (data.WebhookType) {
case "PAYMENT_COMPLETED":
await orderRepository.MarkPaidAsync(data.OrderId, data.ReceiptId, data.Price);
break;
case "PAYMENT_CANCELLED":
await orderRepository.MarkRefundedByReceiptAsync(data.ReceiptId);
break;
case "PAYMENT_PARTIAL_CANCELLED":
var remain = Convert.ToDouble(receipt.GetValueOrDefault("remain_price", 0));
await orderRepository.MarkPartiallyRefundedAsync(data.ReceiptId, remain);
break;
case "PAYMENT_VIRTUAL_ACCOUNT_ISSUED":
await orderRepository.MarkPendingDepositAsync(data.OrderId, data.ReceiptId);
break;
default: // 구버전 SDK fallback
if (data.Status == 1) await orderRepository.MarkPaidAsync(data.OrderId, data.ReceiptId, data.Price);
else if (data.Status == 20) await orderRepository.MarkRefundedByReceiptAsync(data.ReceiptId);
else if (data.Status == 5) await orderRepository.MarkPendingDepositAsync(data.OrderId, data.ReceiptId);
break;
}
return Ok(new { Status = 200 });
}csharp웹훅 데이터는 위변조될 수 있어요. 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 |
가상계좌발급완료 (입금 대기) | 입금 안내 표시 | 권장 |
전체취소(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@app.post('/webhook/bootpay')
def payment_webhook():
data = request.get_json()
webhook_type = data.get('webhook_type')
# 오류 웹훅은 검증 전에 처리
if webhook_type and (webhook_type.endswith('_FAILED') or webhook_type == 'ERROR'):
Order.mark_failed(data['order_id'], reason=webhook_type)
return jsonify({'success': True})
receipt = bootpay.receipt_payment(data['receipt_id'])
if receipt['status'] != data['status'] or receipt['price'] != data['price']:
return jsonify({'success': True})
if webhook_type == 'PAYMENT_COMPLETED':
Order.mark_paid(data['order_id'], receipt)
elif webhook_type == 'PAYMENT_CANCELLED':
Order.mark_refunded_by_receipt(data['receipt_id'])
elif webhook_type == 'PAYMENT_PARTIAL_CANCELLED':
Order.mark_partially_refunded(
data['receipt_id'],
remaining=receipt.get('remain_price'),
cancelled=receipt.get('cancelled_price'))
elif webhook_type == 'PAYMENT_VIRTUAL_ACCOUNT_ISSUED':
Order.mark_pending_deposit(data['order_id'], data['receipt_id'])
else: # 구버전 SDK fallback
if data['status'] == 1:
Order.mark_paid(data['order_id'], receipt)
elif data['status'] == 20:
Order.mark_refunded_by_receipt(data['receipt_id'])
elif data['status'] == 5:
Order.mark_pending_deposit(data['order_id'], data['receipt_id'])
return jsonify({'success': True})python$data = json_decode(file_get_contents('php://input'), true);
$webhookType = $data['webhook_type'] ?? null;
// 오류 웹훅은 검증 전에 처리
if ($webhookType && (str_ends_with($webhookType, '_FAILED') || $webhookType === 'ERROR')) {
Order::markFailed($data['order_id'], $webhookType);
echo json_encode(['success' => true]);
exit;
}
$receipt = BootpayApi::receiptPayment($data['receipt_id']);
if ($receipt['status'] !== $data['status'] || $receipt['price'] !== $data['price']) {
echo json_encode(['success' => true]);
exit;
}
switch ($webhookType) {
case 'PAYMENT_COMPLETED':
Order::markPaid($data['order_id'], $receipt);
break;
case 'PAYMENT_CANCELLED':
Order::markRefundedByReceipt($data['receipt_id']);
break;
case 'PAYMENT_PARTIAL_CANCELLED':
Order::markPartiallyRefunded($data['receipt_id'], $receipt['remain_price'] ?? 0);
break;
case 'PAYMENT_VIRTUAL_ACCOUNT_ISSUED':
Order::markPendingDeposit($data['order_id'], $data['receipt_id']);
break;
default: // 구버전 SDK fallback
if ($data['status'] === 1) Order::markPaid($data['order_id'], $receipt);
elseif ($data['status'] === 20) Order::markRefundedByReceipt($data['receipt_id']);
elseif ($data['status'] === 5) Order::markPendingDeposit($data['order_id'], $data['receipt_id']);
}
echo json_encode(['success' => true]);php@PostMapping("/webhook/bootpay")
public Map<String, Boolean> paymentWebhook(@RequestBody Map<String, Object> data) throws Exception {
String webhookType = (String) data.get("webhook_type");
String orderId = (String) data.get("order_id");
String receiptId = (String) data.get("receipt_id");
// 오류 웹훅은 검증 전에 처리
if (webhookType != null && (webhookType.endsWith("_FAILED") || webhookType.equals("ERROR"))) {
orderService.markFailed(orderId, webhookType);
return Map.of("success", true);
}
var receipt = bootpay.getReceipt(receiptId);
int status = ((Number) data.get("status")).intValue();
double price = ((Number) data.get("price")).doubleValue();
if (((Number) receipt.get("status")).intValue() != status ||
((Number) receipt.get("price")).doubleValue() != price) {
return Map.of("success", true);
}
switch (webhookType == null ? "" : webhookType) {
case "PAYMENT_COMPLETED" -> orderService.markPaid(orderId, receipt);
case "PAYMENT_CANCELLED" -> orderService.markRefundedByReceipt(receiptId);
case "PAYMENT_PARTIAL_CANCELLED" -> orderService.markPartiallyRefunded(
receiptId,
((Number) receipt.getOrDefault("remain_price", 0)).doubleValue()
);
case "PAYMENT_VIRTUAL_ACCOUNT_ISSUED" -> orderService.markPendingDeposit(orderId, receiptId);
default -> { // 구버전 SDK fallback
if (status == 1) orderService.markPaid(orderId, receipt);
else if (status == 20) orderService.markRefundedByReceipt(receiptId);
else if (status == 5) orderService.markPendingDeposit(orderId, receiptId);
}
}
return Map.of("success", true);
}javapost '/webhook/bootpay' do
data = JSON.parse(request.body.read)
webhook_type = data['webhook_type']
# 오류 웹훅은 검증 전에 처리
if webhook_type && (webhook_type.end_with?('_FAILED') || webhook_type == 'ERROR')
Order.mark_failed(data['order_id'], reason: webhook_type)
return { success: true }.to_json
end
receipt = bootpay.verify(data['receipt_id']).data
unless receipt['status'] == data['status'] && receipt['price'] == data['price']
return { success: true }.to_json
end
case webhook_type
when 'PAYMENT_COMPLETED'
Order.mark_paid(data['order_id'], receipt)
when 'PAYMENT_CANCELLED'
Order.mark_refunded_by_receipt(data['receipt_id'])
when 'PAYMENT_PARTIAL_CANCELLED'
Order.mark_partially_refunded(data['receipt_id'], remaining: receipt['remain_price'])
when 'PAYMENT_VIRTUAL_ACCOUNT_ISSUED'
Order.mark_pending_deposit(data['order_id'], data['receipt_id'])
else # 구버전 SDK fallback
case data['status']
when 1 then Order.mark_paid(data['order_id'], receipt)
when 20 then Order.mark_refunded_by_receipt(data['receipt_id'])
when 5 then Order.mark_pending_deposit(data['order_id'], data['receipt_id'])
end
end
{ success: true }.to_json
endrubyfunc paymentWebhook(w http.ResponseWriter, r *http.Request) {
var data struct {
ReceiptID string `json:"receipt_id"`
OrderID string `json:"order_id"`
Status int `json:"status"`
Price float64 `json:"price"`
WebhookType string `json:"webhook_type"`
}
json.NewDecoder(r.Body).Decode(&data)
// 오류 웹훅은 검증 전에 처리
if strings.HasSuffix(data.WebhookType, "_FAILED") || data.WebhookType == "ERROR" {
markOrderFailed(data.OrderID, data.WebhookType)
json.NewEncoder(w).Encode(map[string]bool{"success": true})
return
}
receipt, err := api.GetReceipt(data.ReceiptID)
if err != nil {
json.NewEncoder(w).Encode(map[string]bool{"success": true})
return
}
status := int(receipt["status"].(float64))
price := receipt["price"].(float64)
if status != data.Status || price != data.Price {
json.NewEncoder(w).Encode(map[string]bool{"success": true})
return
}
switch data.WebhookType {
case "PAYMENT_COMPLETED":
markOrderPaid(data.OrderID, receipt)
case "PAYMENT_CANCELLED":
markOrderRefundedByReceipt(data.ReceiptID)
case "PAYMENT_PARTIAL_CANCELLED":
remain, _ := receipt["remain_price"].(float64)
markOrderPartiallyRefunded(data.ReceiptID, remain)
case "PAYMENT_VIRTUAL_ACCOUNT_ISSUED":
markOrderPendingDeposit(data.OrderID, data.ReceiptID)
default: // 구버전 SDK fallback
switch data.Status {
case 1:
markOrderPaid(data.OrderID, receipt)
case 20:
markOrderRefundedByReceipt(data.ReceiptID)
case 5:
markOrderPendingDeposit(data.OrderID, data.ReceiptID)
}
}
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}go[HttpPost("/webhook/bootpay")]
public async Task<IActionResult> PaymentWebhook([FromBody] BootpayWebhook data)
{
// 오류 웹훅은 검증 전에 처리
if (data.WebhookType != null && (data.WebhookType.EndsWith("_FAILED") || data.WebhookType == "ERROR")) {
await orderService.MarkFailedAsync(data.OrderId, data.WebhookType);
return Ok(new { Success = true });
}
var response = await bootpay.GetReceipt(data.ReceiptId);
var body = await response.Content.ReadAsStringAsync();
var receipt = JsonConvert.DeserializeObject<Dictionary<string, object>>(body);
if (Convert.ToInt32(receipt["status"]) != data.Status ||
Convert.ToDouble(receipt["price"]) != data.Price) {
return Ok(new { Success = true });
}
switch (data.WebhookType) {
case "PAYMENT_COMPLETED":
await orderService.MarkPaidAsync(data.OrderId, receipt);
break;
case "PAYMENT_CANCELLED":
await orderService.MarkRefundedByReceiptAsync(data.ReceiptId);
break;
case "PAYMENT_PARTIAL_CANCELLED":
var remain = Convert.ToDouble(receipt.GetValueOrDefault("remain_price", 0));
await orderService.MarkPartiallyRefundedAsync(data.ReceiptId, remain);
break;
case "PAYMENT_VIRTUAL_ACCOUNT_ISSUED":
await orderService.MarkPendingDepositAsync(data.OrderId, data.ReceiptId);
break;
default: // 구버전 SDK fallback
if (data.Status == 1) await orderService.MarkPaidAsync(data.OrderId, receipt);
else if (data.Status == 20) await orderService.MarkRefundedByReceiptAsync(data.ReceiptId);
else if (data.Status == 5) await orderService.MarkPendingDepositAsync(data.OrderId, data.ReceiptId);
break;
}
return Ok(new { Success = true });
}csharp결제 웹훅의 receipt_id를 사용해 Bootpay 서버에서 직접 영수증을 조회하여, 금액(price)과 상태(status)가 일치하는지 확인해야 해요.
가상계좌는 웹훅이 두 번 올 수 있어요: 발급 시(status: 5, webhook_type: PAYMENT_VIRTUAL_ACCOUNT_ISSUED) → 입금 완료 시(status: 1, webhook_type: PAYMENT_COMPLETED). 두 이벤트를 모두 처리해야 해요.
webhook_type 처리 권장 전략
webhook_type을 1차 분기 기준으로 사용해요. SDK 5.x.x 이상에서는 항상 채워져 들어와요.status만으로 부분취소/전체취소를 구분할 수 없어요.default(fallback)로status분기를 남겨두세요. 구버전 SDK·미정의 이벤트 대비용이에요. 단, fallback 경로에서는 부분취소를 식별할 수 없다는 점을 인지해야 해요.- 오류 웹훅(
*_FAILED,ERROR)은 결제 조회 API 검증 전에 분기해요. 오류 페이로드는 정상 영수증 조회가 안 될 수 있으므로webhook_type만으로 처리하고success: true를 빠르게 반환해야 재시도 폭주를 막을 수 있어요. 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 관리자 웹훅 설정에 등록하면 로컬에서도 웹훅을 수신할 수 있어요.
