웹훅 이벤트별로 가맹점이 수행할 처리를 정리합니다. URL 등록과 기본 코드는 웹훅 설정에서 확인합니다.
| # | 조건 | 예시 |
|---|---|---|
| 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 |
가상계좌발급완료 (입금 대기) | 입금 안내 표시 | 권장 |
부분취소는 잔액이 남아 있어 결제 상태가 유지됩니다. 그래서 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@app.post('/webhook/bootpay')
def payment_webhook():
data = request.get_json()
webhook_type = data.get('webhook_type')
# 오류 웹훅은 검증 전에 처리 — order_id는 최상위가 아니라 payload 안에 있습니다
if webhook_type and (webhook_type.endswith('_FAILED') or webhook_type == 'ERROR'):
payload = data.get('payload') or {}
Order.mark_failed(payload.get('order_id'),
reason=data.get('error_code') or webhook_type,
message=data.get('message'))
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'],
# 잔여 금액은 별도 필드가 아니라 receipt['price'] 자체입니다 (원금 − 누적취소)
remaining=receipt['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;
// 오류 웹훅은 검증 전에 처리 — order_id는 최상위가 아니라 payload 안에 있습니다
if ($webhookType && (str_ends_with($webhookType, '_FAILED') || $webhookType === 'ERROR')) {
$payload = $data['payload'] ?? [];
Order::markFailed($payload['order_id'] ?? null, $data['error_code'] ?? $webhookType, $data['message'] ?? null);
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':
// 잔여 금액은 별도 필드가 아니라 price 자체입니다 (원금 − 누적취소)
Order::markPartiallyRefunded($data['receipt_id'], $receipt['price']);
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");
// 오류 웹훅은 검증 전에 처리 — order_id는 최상위가 아니라 payload 안에 있습니다
if (webhookType != null && (webhookType.endsWith("_FAILED") || webhookType.equals("ERROR"))) {
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) data.getOrDefault("payload", Map.of());
orderService.markFailed((String) payload.get("order_id"), (String) data.get("error_code"));
return Map.of("success", true);
}
String orderId = (String) data.get("order_id");
String receiptId = (String) data.get("receipt_id");
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,
// 잔여 금액은 별도 필드가 아니라 price 자체입니다 (원금 − 누적취소)
((Number) receipt.get("price")).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']
# 오류 웹훅은 검증 전에 처리 — order_id는 최상위가 아니라 payload 안에 있습니다
if webhook_type && (webhook_type.end_with?('_FAILED') || webhook_type == 'ERROR')
payload = data['payload'] || {}
Order.mark_failed(payload['order_id'], reason: data['error_code'] || webhook_type, message: data['message'])
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'
# 잔여 금액은 별도 필드가 아니라 price 자체입니다 (원금 − 누적취소)
Order.mark_partially_refunded(data['receipt_id'], remaining: receipt['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"`
// 오류 웹훅은 order_id / receipt_id / status가 payload 안에 들어옵니다
ErrorCode string `json:"error_code"`
Message string `json:"message"`
Payload struct {
ReceiptID string `json:"receipt_id"`
OrderID string `json:"order_id"`
Status int `json:"status"`
} `json:"payload"`
}
json.NewDecoder(r.Body).Decode(&data)
// 오류 웹훅은 검증 전에 처리
if strings.HasSuffix(data.WebhookType, "_FAILED") || data.WebhookType == "ERROR" {
markOrderFailed(data.Payload.OrderID, data.ErrorCode)
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":
// 잔여 금액은 별도 필드가 아니라 price 자체입니다 (원금 − 누적취소)
remain, _ := receipt["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)
{
// 오류 웹훅은 검증 전에 처리 — OrderId는 최상위가 아니라 Payload 안에 있습니다
if (data.WebhookType != null && (data.WebhookType.EndsWith("_FAILED") || data.WebhookType == "ERROR")) {
await orderService.MarkFailedAsync(data.Payload?.OrderId, data.ErrorCode ?? 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":
// 잔여 금액은 별도 필드가 아니라 price 자체입니다 (원금 − 누적취소)
var remain = Convert.ToDouble(receipt["price"]);
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차 분기 기준으로 사용합니다. API 버전이 4.10.0 초과(베타 제외) 또는 5.1.6 초과이면 항상 채워져 들어옵니다.status만으로는 부분취소를 포착할 수 없습니다.default(fallback)로status분기를 남겨 둡니다. 낮은 API 버전·미정의 이벤트 대비용입니다. 단, 부분취소는status: 1로 도착하므로 이 경로에서는 결제완료와 구분되지 않아 부분취소 발생 자체를 포착할 수 없다는 점을 인지해야 합니다.- 오류 웹훅(
*_FAILED,ERROR)은 결제 조회 API 검증 전에 분기합니다. 오류 페이로드는 정상 영수증 조회가 안 될 수 있으므로webhook_type만으로 처리하고success: true를 빠르게 반환해야 재시도 폭주를 막을 수 있습니다. webhook_type을 로깅 키로 사용합니다. 운영 모니터링·알림에서 이벤트별 수신량을 집계합니다.
디버깅: 웹훅이 안 올 때
| # | 확인 사항 | 해결 방법 |
|---|---|---|
| 1 | 웹훅 URL이 HTTPS인가? | HTTP도 등록은 되지만 운영에서는 HTTPS를 사용합니다. SSL 인증서가 유효한지 확인 |
| 2 | 관리자에서 이벤트를 선택했는가? | 관리자 → 웹훅 설정에서 필요한 이벤트 체크 |
| 3 | 응답 본문에 {"success": true}를 담았는가? |
이 값이 없으면 HTTP 200이어도 실패로 기록되고 재시도됨 |
| 4 | 방화벽이 차단하지 않는가? | Bootpay 발신 IP 대역 223.130.82.0/24 허용 |
| 5 | 로컬 개발 환경인가? | ngrok 등 터널링 도구 사용 |
| 6 | 테스트 웹훅을 발송해봤는가? | 웹훅 설정에서 테스트 웹훅 발송 기능 사용 |
| 7 | 샌드박스 모드인가? | 샌드박스에서도 웹훅은 정상 발송됨 |
로컬 개발 시 웹훅 받기
로컬 개발 환경에서는 외부에서 접근할 수 없으므로 터널링 도구를 사용합니다.
# ngrok 사용
ngrok http 3000
# → https://abc123.ngrok.io 주소를 웹훅 URL로 등록
# 또는 localtunnel
npx localtunnel --port 3000bash터널링 URL을 Bootpay 관리자 웹훅 설정에 등록하면 로컬에서도 웹훅을 수신할 수 있습니다.



