핵심 요약
- 웹훅은 결제 완료, 취소, 가상계좌 발급 같은 상태 변화를 서버로 즉시 전달해요.
- 웹훅 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은 결제를 요청한 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
}
}jsonpayload는 PG 연동 정보가 있으면 PG 원본 응답이, 없으면 최소한의 영수증 정보가 담겨요. PG마다 구조가 다르므로 고정 스키마를 가정하지 말고 통째로 로깅해요.
- 부분 취소는 잔액이 남아 있어 결제 상태가 유지돼요. 즉
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@app.route('/webhook/bootpay', methods=['POST'])
def webhook():
data = request.get_json()
webhook_type = data.get('webhook_type')
# 오류 웹훅은 검증 전에 처리
# 오류 본문은 {error_code, pg_error_code, message, payload, webhook_type} 구조라
# order_id / receipt_id / status는 최상위가 아니라 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}), 200
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.receipt_payment(receipt_id)
if receipt['status'] != status or receipt['price'] != price:
return jsonify({'success': True}), 200
# 2. webhook_type 우선 분기 (API 버전 4.10.0 초과 또는 5.1.6 초과)
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',
# 잔여 금액은 별도 필드가 아니라 receipt['price'] 자체예요 (원금 − 누적취소)
remaining_amount=receipt['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({'success': True}), 200python$data = json_decode(file_get_contents('php://input'), true);
$webhookType = $data['webhook_type'] ?? null;
// 오류 웹훅은 검증 전에 처리
// 오류 본문은 {error_code, pg_error_code, message, payload, webhook_type} 구조라
// order_id / receipt_id / status는 최상위가 아니라 payload 안에 들어와요
if ($webhookType && (str_ends_with($webhookType, '_FAILED') || $webhookType === 'ERROR')) {
$payload = $data['payload'] ?? [];
Order::where('order_id', $payload['order_id'] ?? null)->update([
'status' => 'failed',
'failure_reason' => $data['error_code'] ?? $webhookType,
'failure_message' => $data['message'] ?? null,
]);
http_response_code(200);
echo json_encode(['success' => true]);
exit;
}
$receiptId = $data['receipt_id'];
$status = $data['status'];
$price = $data['price'];
$orderId = $data['order_id'];
// 1. 결제 조회 API로 검증
$receipt = BootpayApi::receiptPayment($receiptId);
if ($receipt['status'] !== $status || $receipt['price'] !== $price) {
http_response_code(200);
echo json_encode(['success' => true]);
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',
// 잔여 금액은 별도 필드가 아니라 price 자체예요 (원금 − 누적취소)
'remaining_amount' => $receipt['price'],
]);
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(['success' => true]);php@PostMapping("/webhook/bootpay")
public ResponseEntity<?> webhook(@RequestBody Map<String, Object> data) throws Exception {
String webhookType = (String) data.get("webhook_type");
// 오류 웹훅은 검증 전에 처리
// 오류 본문은 {error_code, pg_error_code, message, payload, webhook_type} 구조라
// order_id / receipt_id / status는 최상위가 아니라 payload 안에 들어와요
if (webhookType != null && (webhookType.endsWith("_FAILED") || webhookType.equals("ERROR"))) {
@SuppressWarnings("unchecked")
Map<String, Object> payload = (Map<String, Object>) data.getOrDefault("payload", Map.of());
orderRepository.markFailed((String) payload.get("order_id"), (String) data.get("error_code"));
return ResponseEntity.ok(Map.of("success", true));
}
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");
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("success", true));
}
// 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,
// 잔여 금액은 별도 필드가 아니라 price 자체예요 (원금 − 누적취소)
((Number) receipt.get("price")).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("success", true));
}javapost '/webhook/bootpay' do
data = JSON.parse(request.body.read)
webhook_type = data['webhook_type']
# 오류 웹훅은 검증 전에 처리
# 오류 본문은 {error_code, pg_error_code, message, payload, webhook_type} 구조라
# order_id / receipt_id / status는 최상위가 아니라 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_id = data['receipt_id']
status = data['status']
price = data['price']
order_id = data['order_id']
receipt = bootpay.verify(receipt_id).data
unless receipt['status'] == status && receipt['price'] == price
return { success: true }.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'
# 잔여 금액은 별도 필드가 아니라 price 자체예요 (원금 − 누적취소)
Order.mark_partially_refunded(receipt_id, remaining: receipt['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
{ success: true }.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"`
// 오류 본문은 {error_code, pg_error_code, message, payload, 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, data.ReceiptID, data.Price)
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, data.ReceiptID, data.Price)
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> Webhook([FromBody] BootpayWebhook data)
{
// 오류 웹훅은 검증 전에 처리
// 오류 본문은 {error_code, pg_error_code, message, payload, webhook_type} 구조라
// order_id / receipt_id / status는 최상위가 아니라 payload 안에 들어와요
if (data.WebhookType != null && (data.WebhookType.EndsWith("_FAILED") || data.WebhookType == "ERROR")) {
await orderRepository.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 orderRepository.MarkPaidAsync(data.OrderId, data.ReceiptId, data.Price);
break;
case "PAYMENT_CANCELLED":
await orderRepository.MarkRefundedByReceiptAsync(data.ReceiptId);
break;
case "PAYMENT_PARTIAL_CANCELLED":
// 잔여 금액은 별도 필드가 아니라 price 자체예요 (원금 − 누적취소)
var remain = Convert.ToDouble(receipt["price"]);
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 { Success = true });
}csharp웹훅 데이터는 위변조될 수 있어요. 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 등록과 기본 코드를 확인하세요. 이 문서는 설정 이후, 각 이벤트를 받았을 때 실제로 무엇을 해야 하는지에 집중해요.
| # | 조건 | 예시 |
|---|---|---|
| 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 대역 허용 |
| 5 | 로컬 개발 환경인가? | ngrok 등 터널링 도구 사용 |
| 6 | 테스트 웹훅을 발송해봤는가? | 웹훅 설정에서 테스트 웹훅 발송 기능 사용 |
| 7 | 샌드박스 모드인가? | 샌드박스에서도 웹훅은 정상 발송됨 |
로컬 개발 시 웹훅 받기
로컬 개발 환경에서는 외부에서 접근할 수 없으므로 터널링 도구를 사용해요.
# ngrok 사용
ngrok http 3000
# → https://abc123.ngrok.io 주소를 웹훅 URL로 등록
# 또는 localtunnel
npx localtunnel --port 3000bash터널링 URL을 Bootpay 관리자 웹훅 설정에 등록하면 로컬에서도 웹훅을 수신할 수 있어요.
