결제 완료·취소·구독 변경 등 이벤트 발생 시 서버로 자동 알림을 보내는 기능이에요.
이럴 때 필요해요: 결제/주문/구독 상태 변경을 서버에서 실시간 수신할 때
내가 할 일: 서버 (백엔드) — 웹훅 수신 엔드포인트 구현
설정 순서
- ① 웹훅 URL 등록 -> ② 이벤트 처리 코드 구현
웹훅 URL 등록
부트페이 관리자 > 개발자 설정 > 웹훅 설정에서 웹훅을 수신할 HTTPS 엔드포인트를 등록해요.
::: warning 웹훅 URL은 반드시 HTTPS여야 해요. HTTP URL은 보안상 지원하지 않아요.
이벤트 처리 코드 구현
모든 이벤트가 등록한 URL로 수신돼요. 서버에서 이벤트 타입별로 분기 처리해요. :::
결제 웹훅
이벤트 목록
| 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반드시 결제 조회 API로 검증해요
웹훅 데이터는 위변조될 수 있어요. receipt_id로 결제 조회 API를 호출해 금액과 상태를 반드시 검증한 뒤 처리해요.
재시도 정책
웹훅 수신 실패 시 지수 백오프로 자동 재시도해요.
| 재시도 | 간격 |
|---|---|
| 1회 | 1분 후 |
| 2회 | 5분 후 |
| 3회 | 30분 후 |
| 4회 | 2시간 후 |
| 5회 | 24시간 후 |
커머스 웹훅
주문 이벤트
| 이벤트 | 설명 | 가맹점이 할 일 |
|---|---|---|
order.done |
주문 결제 완료 | 재고 차감, 서비스 활성화 |
order.cancelled |
주문 전체 취소 | 재고 복구, 서비스 비활성화 |
order.partial_cancelled |
부분 취소 | 해당 상품만 재고 복구 |
order.payment_pending |
지연 결제 대기 (무통장 등) | 입금 안내 표시 |
order.payment_error |
결제 실패 | 에러 로그 기록 |
구독 이벤트
| 이벤트 | 설명 | 가맹점이 할 일 |
|---|---|---|
subscription.requested_done |
구독 신청 접수 | 관리자에게 승인 요청 알림 |
subscription.approved |
구독 승인 | 서비스 활성화 (service_active = true) |
subscription.renewed |
회차 결제 성공 (갱신) | 서비스 기간 연장, 다음 결제일 업데이트 |
subscription.modified |
구독 내용 변경 | 변경 내용 반영 |
subscription.paused |
구독 일시정지 | 서비스 일시 제한 |
subscription.resumed |
구독 재개 | 서비스 재활성화 |
subscription.terminated |
구독 해지 (고객) | 서비스 비활성화 (service_active = false) |
subscription.admin_terminated |
구독 해지 (관리자) | 서비스 비활성화 + 고객 안내 |
subscription.expired |
약정 만료 | 서비스 비활성화 |
subscription.trial_started |
체험 시작 | 서비스 활성화 (제한적) |
페이로드 구조
{
"project_key": "your-project-uuid",
"idempotency_key": "unique-key-for-deduplication",
"webhook_type": "order.done",
"payload": {
"order_id": "68707c59b0eacea5cd974efd",
"order_number": "ORDER_12345",
"price": 29900,
"status": 2,
"purchased_at": "2024-01-01T12:00:00+09:00",
"products": [...],
"user": { "user_id": "...", "username": "..." }
}
}json| 필드 | 타입 | 설명 |
|---|---|---|
project_key |
String | 프로젝트 식별자 |
idempotency_key |
String | 중복 처리 방지를 위한 고유 키 |
webhook_type |
String | 이벤트 타입 (order.done, subscription.approved 등) |
payload |
Object | 이벤트별 데이터 (주문/구독 상세) |
수신 코드
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', bootpay_order_id: payload.order_id, paid_at: new Date() },
{ where: { bootpay_order_number: payload.order_number } }
)
break
case 'order.cancelled':
await db.orders.update(
{ status: 'refunded' },
{ where: { bootpay_order_id: payload.order_id } }
)
break
case 'subscription.approved':
await db.subscriptions.update(
{ service_active: true, status: 'active' },
{ where: { bootpay_subscription_id: payload.order_subscription_id } }
)
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', bootpay_order_id=payload['order_id'])
elif webhook_type == 'order.cancelled':
Order.update_by_order_id(payload['order_id'], status='refunded')
elif webhook_type == 'subscription.approved':
Subscription.activate(payload['order_subscription_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', 'bootpay_order_id' => $payload['order_id']]);
break;
case 'order.cancelled':
Order::where('bootpay_order_id', $payload['order_id'])
->update(['status' => 'refunded']);
break;
case 'subscription.approved':
Subscription::where('bootpay_subscription_id', $payload['order_subscription_id'])
->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초 |
| 최대 재시도 | 25회 (기본 3회) |
| 재시도 조건 | HTTP 200 이외 응답 또는 타임아웃 |
공통 주의사항
- 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더 자세한 웹훅 처리 가이드
이벤트별 코드 예시, 멱등성 보장 패턴, 디버깅 체크리스트는 웹훅 처리 가이드를 참고해요.
