리셀러 웹훅은 PG 신청 단계 변화를, 커머스 웹훅은 하위 프로젝트의 주문·구독 이벤트를 보냅니다. 설정 API 와 서명 방식이 서로 다릅니다.
| API | 하는 일 |
|---|---|
| 1. 리셀러 웹훅 | PG 신청 단계 변화를 리셀러 서버로 받기 |
| 2. 커머스 웹훅 설정 | 하위 프로젝트의 주문·구독 웹훅 주소와 이벤트 |
인증·권한 오류는 각 표에 없어도 공통 오류 가 날 수 있습니다.
1리셀러 웹훅
하위 판매자의 PG 신청 단계가 바뀌거나 MID 연결이 끝나면, 부트페이가 리셀러가 등록한 주소로 서명된
POST를 보냅니다. 결제 완료를 알리는 가맹점 웹훅 과는 다른 통로이고 모양·서명도 다릅니다.
동작 규칙
- 받을 주소와 이벤트는
PUT /v2/reseller/webhook으로 등록하고, 지금 설정은GET으로 봅니다. 서명 비밀(secret)은 처음 설정할 때와 교체할 때 한 번만 응답에 나옵니다. - 모든 요청에
X-Bootpay-Signature가 붙습니다. 받은 본문 그대로(파싱하기 전 바이트)로 서명을 다시 계산해 맞는지 봅니다. - 처리했으면 HTTP
200과{"success": true}로 답합니다. 그 밖의 응답·시간 초과는 다시 보냅니다(최대 10번). - 같은 이벤트가 두 번 올 수 있고 순서가 뒤바뀔 수 있습니다 →
X-Bootpay-Event-Id로 중복을 거르고, 최신 상태는 등록 상태 조회 로 맞춥니다.
1-1. 설정 — 받을 주소 등록
https://api.bootpay.co.kr/v2/reseller/webhookBasic Auth (리셀러 계정 키)| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
url |
String | 필수 | 받을 주소. 500자 이하 https://, 외부에서 접속할 수 있는 주소만. 빈 값이면 웹훅을 끕니다 |
events |
Array | 선택 | 받을 이벤트 이름(아래 이벤트 표). 비우면 전부 받습니다 |
rotate_secret |
Boolean | 선택 | true 면 서명 비밀을 새로 만듭니다. 그 뒤 보내는 웹훅부터 새 비밀로 서명합니다 |
url 키를 빼도 오류 없이 웹훅이 꺼지고, events 를 빼면 모든 이벤트를 받도록 바뀝니다. 서명 비밀만 교체할 때도 지금 url·events 를 함께 보냅니다.
{
"url": "https://reseller.example.com/bootpay/webhook",
"events": ["seller.onboarding.changed", "seller.activated"],
"secret_set": true,
"updated_at": "2026-10-27T15:00:00+09:00",
"secret": "whsec_example_secret"
}json| 파라미터 | 타입 | 설명 |
|---|---|---|
| url | String | 등록된 주소(꺼져 있으면 null) |
| events | Array | 받을 이벤트(빈 배열 = 전부) |
| secret_set | Boolean | 서명 비밀이 있는지 |
| updated_at | String | 마지막 설정 시각(ISO 8601) |
| secret | String | 서명 비밀 원문. 처음 설정·rotate_secret=true 응답에만 있습니다. 다시 볼 수 없으니 바로 안전한 곳에 보관합니다 |
1-2. 설정 조회
https://api.bootpay.co.kr/v2/reseller/webhookBasic Auth (리셀러 계정 키)요청 파라미터는 없습니다. 설정 응답과 같은 모양에서 secret 만 빠집니다.
{
"url": "https://reseller.example.com/bootpay/webhook",
"events": ["seller.onboarding.changed", "seller.activated"],
"secret_set": true,
"updated_at": "2026-10-27T15:00:00+09:00"
}json설정한 적이 없으면 url·updated_at 은 null, events 는 빈 배열, secret_set 은 false입니다. 범위 키로 부를 때 설정은 reseller:webhook:write, 조회는 reseller:webhook:read 스코프가 필요합니다.
1-3. 이벤트
| event | 언제 | 지금 |
|---|---|---|
seller.onboarding.changed |
하위 판매자의 PG 신청이 접수되거나 상태가 바뀔 때. 메모만 남긴 변경은 보내지 않습니다. 등록 API 접수 로 낸 접수 자체도 보내지 않고, 그 뒤 심사 상태가 바뀌면 보냅니다 | 보냄 |
seller.activated |
PG 승인 뒤 MID 가 하위 프로젝트에 연결됐을 때 | 보냄 |
seller.agreement.required |
약관 새 버전이 시행돼, 이전 버전에 동의한 하위 가맹점이 다시 동의해야 할 때 | 보냄 |
seller.created |
하위 가맹점이 만들어졌을 때 | 예정 |
seller.commerce.failed |
하위 프로젝트의 커머스 연동이 실패했을 때 | 예정 |
seller.settle_blocked |
하위 정산이 막혔을 때 | 예정 |
판매자가 부트페이 콘솔에서 직접 낸 신청이라도 그 가맹점이 리셀러 하위이면 상태 변경 웹훅이 옵니다.
1-4. 받는 요청
POST /bootpay/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Bootpay-Webhook/1.0
X-Bootpay-Event: seller.onboarding.changed
X-Bootpay-Event-Id: evt_0f3c9a1b2d4e5f60718293a4
X-Bootpay-Signature: t=1793080800,v1=5d1c...(64자 hex)
{"id":"evt_0f3c9a1b2d4e5f60718293a4","event":"seller.onboarding.changed","created_at":"2026-10-27T15:00:00+09:00","data":{"provider_id":"65a1c0aa8f1b5b00367a0001","onboarding_id":"6718a0aa8f1b5b00367a0101","kind":"pg_join","status":1,"status_name":"구비서류 전달 필요","stage":"doc_wait","stage_owner":"merchant","due_at":"2026-10-30T18:00:00+09:00","reject_reason":null}}| 필드 | 설명 |
|---|---|
| id | 이벤트 id(evt_…). 재시도해도 같습니다 — 헤더 X-Bootpay-Event-Id 와 같은 값 |
| event | 이벤트 이름 |
| created_at | 이벤트가 생긴 시각(ISO 8601) |
| data | seller.onboarding.changed 는 등록 상태 조회 항목과 같은 이름의 값(provider_id onboarding_id kind status status_name stage stage_owner due_at reject_reason). seller.activated 는 provider_id onboarding_id kind app_id(MID 를 연결한 하위 프로젝트) pg methods(결제수단 별칭 배열) mid(가린 값) sandbox(false) activated_at. seller.agreement.required 는 provider_id code version title effective_at |
1-5. 서명 검증
X-Bootpay-Signature: t=<unix 초>,v1=<hex> 의 v1 은 HMAC-SHA256(서명 비밀, "<t>.<받은 본문>") 의 hex 값입니다.
- 헤더에서
t와v1을 꺼냅니다. - 받은 본문 바이트 그대로(JSON 파싱·재직렬화하지 않은 원문)로
"<t>.<본문>"을 만듭니다. - 보관한 서명 비밀로 HMAC-SHA256 을 계산해
v1과 시간 일정 비교로 맞춥니다. t가 지금보다 5분 넘게 지났으면 버립니다(재전송 공격 방지).
import crypto from 'node:crypto'
import express from 'express'
const app = express()
const SECRET = process.env.BOOTPAY_RESELLER_WEBHOOK_SECRET
// 서명은 원문 바이트로 계산한다 — express.json() 보다 앞에서 raw 로 받는다
app.post('/bootpay/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-Bootpay-Signature') || ''
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')))
const expected = crypto.createHmac('sha256', SECRET).update(`${parts.t}.${req.body}`).digest('hex')
const valid = parts.v1 && expected.length === parts.v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
if (!valid || Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return res.status(400).end()
const event = JSON.parse(req.body)
// event.id 로 이미 처리한 이벤트인지 먼저 확인한다
res.json({ success: true })
})javascriptimport hashlib, hmac, json, os, time
from flask import Flask, request, jsonify
app = Flask(__name__)
SECRET = os.environ['BOOTPAY_RESELLER_WEBHOOK_SECRET'].encode()
@app.post('/bootpay/webhook')
def bootpay_webhook():
raw = request.get_data() # 파싱 전 원문 바이트
parts = dict(p.split('=', 1) for p in request.headers.get('X-Bootpay-Signature', '').split(',') if '=' in p)
expected = hmac.new(SECRET, f"{parts.get('t', '')}.".encode() + raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, parts.get('v1', '')) or abs(time.time() - int(parts.get('t', 0))) > 300:
return '', 400
event = json.loads(raw)
# event['id'] 로 이미 처리한 이벤트인지 먼저 확인한다
return jsonify(success=True)python1-6. 재시도
| 상황 | 동작 |
|---|---|
HTTP 200 + {"success": true} |
성공 — 더 보내지 않습니다 |
그 밖의 응답 코드, success 가 없거나 false |
다시 보냅니다 |
| 연결 3초·응답 5초 안에 끝나지 않음 | 다시 보냅니다 |
| 10번 보내도 실패 | 멈추고 리셀러 담당자에게 알립니다 |
| 주소가 내부·사설 IP 로 풀리거나 서명 비밀이 없음 | 보내지 않습니다 |
다시 보낼 때도 id·본문은 처음과 같습니다. 서명(t·v1)은 보낼 때마다 새로 계산합니다. 시간이 오래 걸리는 처리는 먼저 200 으로 답하고 뒤에서 합니다.
1-7. 에러 코드 (설정 API)
| 코드 | 메시지 | 대처 방법 |
|---|---|---|
API_ (601) |
리셀러만 이용이 가능한 API입니다 | 리셀러 권한 계정 키로 호출합니다 |
RESELLER_ (343) |
본사가 리셀러 사용을 멈췄습니다 (HTTP 403) | 부트페이에 문의합니다 |
RESELLER_ (8130) |
웹훅 주소 형식이 맞지 않습니다 | 500자 이하의 https:// 주소로 보냅니다 |
RESELLER_ (8131) |
내부·사설 주소로는 보낼 수 없습니다 | 외부에서 접속할 수 있는 주소를 넣습니다 |
RESELLER_ (8132) |
받을 수 없는 이벤트가 들어 있습니다 | 위 이벤트 표의 이름만 보냅니다 |
2커머스 웹훅 설정
하위 프로젝트에 연결된 부트페이 커머스 프로젝트가 주문·구독 웹훅을 보낼 주소와 받을 이벤트를 리셀러가 정합니다. 판매자가 커머스 관리자 화면(개발 연동 설정)에서 정하는 웹훅과 같은 설정입니다. PG 신청 단계를 알려 주는 리셀러 웹훅 과는 다른 통로입니다.
동작 규칙
PUT은 주소(url)와 이벤트(events)를 한 번에 바꿉니다.url은 꼭 보냅니다 — 빈 문자열("")이면 웹훅을 끕니다. 키를 빼면400입니다(실수로 꺼지지 않게).events를 비우면 모든 주문·구독 이벤트를 받습니다(지금 판매자 설정과 같은 기본값). 고르면 고른 이벤트만 보냅니다.- 주소는 500자 이하의
https://주소만 받습니다. 이름을 풀어 나온 IP 가 내부·사설·부트페이 서버 주소면 거부합니다. - 주소는 새로 만들어지는 주문·구독부터 쓰입니다(주문·구독이 만들어질 때 주소를 복사해 둡니다). 받을 이벤트는 보낼 때마다 지금 설정을 봅니다.
- 재시도 횟수와 본문 형식(Content-Type)은 그 커머스 프로젝트의 지금 값을 그대로 사용합니다(기본 10번 ·
application/x-www-form-urlencoded). 바꾸려면 커머스 관리자 화면에서 바꿉니다. - 커머스 프로젝트가 아직 없으면
404입니다. 커머스 연동 재시도 로 먼저 만듭니다.
API 정보
https://api.bootpay.co.kr/v2/reseller/seller/app/commerce/:app_id/webhookBasic Auth (리셀러 계정 키)지금 설정은 GET 으로 봅니다. 요청 본문 없이 아래 응답 과 같은 모양으로 옵니다.
https://api.bootpay.co.kr/v2/reseller/seller/app/commerce/:app_id/webhookBasic Auth (리셀러 계정 키)범위 키로 부를 때 설정은 reseller:app:write, 조회는 reseller:app:read 스코프가 필요합니다.
2-1. 요청 파라미터
| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
app_id |
String | 필수 | 웹훅을 설정할 하위 프로젝트의 app_id (URL 경로) |
url |
String | 필수 | 웹훅을 받을 주소. 500자 이하 https://, 외부에서 접속할 수 있는 주소만. 빈 문자열이면 웹훅을 끕니다 |
events |
Array | 선택 | 받을 이벤트 이름(아래 이벤트 표). 비우거나 빼면 전부 받습니다 |
2-2. 코드 예제
import fetch from 'node-fetch'
const RESELLER_AUTH = 'Basic ' + Buffer
.from(`${process.env.BOOTPAY_RESELLER_CLIENT_KEY}:${process.env.BOOTPAY_RESELLER_SECRET_KEY}`)
.toString('base64')
const appId = '65a1c0ab8f1b5b00367a0002'
const res = await fetch(`https://api.bootpay.co.kr/v2/reseller/seller/app/commerce/${appId}/webhook`, {
method: 'PUT',
headers: {
'Authorization': RESELLER_AUTH,
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: 'https://reseller.example.com/bootpay/commerce-webhook',
events: ['order.done', 'order.cancelled', 'subscription.approved']
})
})
console.log(await res.json())javascriptimport base64, os, requests
auth = 'Basic ' + base64.b64encode(
f"{os.environ['BOOTPAY_RESELLER_CLIENT_KEY']}:{os.environ['BOOTPAY_RESELLER_SECRET_KEY']}".encode()
).decode()
app_id = '65a1c0ab8f1b5b00367a0002'
res = requests.put(
f'https://api.bootpay.co.kr/v2/reseller/seller/app/commerce/{app_id}/webhook',
headers={'Authorization': auth},
json={
'url': 'https://reseller.example.com/bootpay/commerce-webhook',
'events': ['order.done', 'order.cancelled', 'subscription.approved']
}
)
print(res.json())pythonRESELLER_AUTH=$(printf '%s' "$BOOTPAY_RESELLER_CLIENT_KEY:$BOOTPAY_RESELLER_SECRET_KEY" | base64)
APP_ID='65a1c0ab8f1b5b00367a0002'
curl -X PUT "https://api.bootpay.co.kr/v2/reseller/seller/app/commerce/$APP_ID/webhook" \
-H "Authorization: Basic $RESELLER_AUTH" \
-H 'Content-Type: application/json' \
-d '{"url":"https://reseller.example.com/bootpay/commerce-webhook","events":["order.done","order.cancelled","subscription.approved"]}'bash2-3. 응답
{
"app_id": "65a1c0ab8f1b5b00367a0002",
"project_id": "65a1c0ab8f1b5b00367a0006",
"url": "https://reseller.example.com/bootpay/commerce-webhook",
"events": ["order.done", "order.cancelled", "subscription.approved"],
"retry_count": 10,
"content_type": "application/x-www-form-urlencoded"
}json응답 파라미터
| 파라미터 | 타입 | 설명 |
|---|---|---|
| app_id | String | 하위 프로젝트 식별자 |
| project_id | String | 커머스 프로젝트 식별자 |
| url | String | 웹훅 주소. 꺼져 있으면 null |
| events | Array | 받을 이벤트 이름. 빈 배열 = 전부 |
| retry_count | Number | 실패했을 때 다시 보내는 최대 횟수(커머스 프로젝트 설정) |
| content_type | String | 웹훅 본문 형식 application/x-www-form-urlencoded 또는 application/json(커머스 프로젝트 설정) |
2-4. 이벤트
본문의 webhook_type 으로 어떤 이벤트인지 압니다. 결제완료·취소·부분취소 중 일부 경로는 본문에 옛 표기(ORDER_COMPLETED · ORDER_CANCELLED · ORDER_PARTIAL_CANCELLED)로 오는데, 고를 때는 아래 이름(order.done · order.cancelled · order.partial_cancelled)을 사용합니다.
| 분류 | 이벤트 |
|---|---|
| 주문 | order.done order.confirm_error order.cancelled order.cancel_error order.transaction_pending order.partial_cancelled |
| 구독 신청·승인 | subscription.requested_done subscription.approved subscription.requested_error subscription.rejected subscription.trial_started subscription.trial_ending subscription.trial_ended |
| 구독 변경 | subscription.modified subscription.upgraded subscription.downgraded subscription.transfer_requested subscription.transfer_approved subscription.extended |
| 구독 정지·재개 | subscription.paused subscription.resumed subscription.hold_on |
| 구독 종료 | subscription.terminated subscription.admin_terminated subscription.expired subscription.expiring_soon subscription.restored |
| 구독 갱신 | subscription.renewed subscription.renewal_upcoming |
| 구독 쿠폰 | subscription_coupon.applied subscription_coupon.removed subscription_coupon.expired |
알림톡 발송 결과(alimtalk.*)와 입금 확인(deposit.*)은 각자 설정이 따로 있어 여기서 고를 수 없습니다.
2-5. 받는 요청
- 커머스 프로젝트 설정의 형식(
content_type)으로POST합니다. 서명 헤더는 없습니다 — 받은 주문·구독은 커머스 API 로 다시 조회해 확인합니다. - 처리했으면 응답 본문에
{"success": true}를 담습니다. 없거나false이면retry_count만큼 다시 보냅니다. - 같은 이벤트가 두 번 올 수 있습니다. 본문의
idempotency_key나 주문·구독 id 로 중복을 거릅니다.
2-6. 에러 코드
| 코드 | 메시지 | 대처 방법 |
|---|---|---|
COMMERCE_ (8190) |
커머스 웹훅 주소나 이벤트를 확인해 주세요 (HTTP 400) | url 키를 보냈는지, 500자 이하 https:// 외부 주소인지, 이벤트 이름이 위 표에 있는지 봅니다. 원인은 payload.commerce_error_code(WEBHOOK_URL_INVALID 형식·호스트 · PROJECT_WEBHOOK_URL_FORBIDDEN 내부·사설 주소 · PROJECT_WEBHOOK_EVENT_INVALID 이벤트) |
COMMERCE_ (8191) |
이 프로젝트에 연결된 커머스 프로젝트가 없습니다 (HTTP 404) | 커머스 연동 재시도 로 커머스 프로젝트를 먼저 만듭니다 |
COMMERCE_ (8182) |
커머스 연동 서버에 요청하지 못했습니다 | 잠시 후 다시 부릅니다. 같은 값으로 다시 불러도 안전합니다. 원인은 payload.commerce_error_code |
APP_ |
프로젝트를 찾지 못했습니다 | app_id 가 올바른지 확인합니다 |
APP_ |
본인 하위 프로젝트가 아닙니다 | app_id 가 본인 리셀러 하위 셀러의 프로젝트인지 확인합니다 |
API_ |
리셀러만 이용이 가능한 API입니다 | 리셀러 권한 계정 키로 호출합니다 |





