하위 판매자의 PG 신청 단계가 바뀌거나 MID 연결이 끝나면, 부트페이가 리셀러가 등록한 주소로 서명된
POST를 보낸다. 결제 완료를 알리는 가맹점 웹훅 과는 다른 통로이고 모양·서명도 다르다.
핵심 요약
- 받을 주소와 이벤트는
PUT /v2/reseller/webhook으로 등록한다. 서명 비밀(secret)은 처음 설정할 때와 교체할 때 한 번만 응답에 나온다. - 모든 요청에
X-Bootpay-Signature가 붙는다. 받은 본문 그대로(파싱하기 전 바이트)로 서명을 다시 계산해 맞는지 본다. - 처리했으면 HTTP
200과{"success": true}로 답한다. 그 밖의 응답·시간 초과는 다시 보낸다(최대 10번). - 같은 이벤트가 두 번 올 수 있고 순서가 뒤바뀔 수 있다 →
X-Bootpay-Event-Id로 중복을 거르고, 최신 상태는 등록 상태 조회 로 맞춘다.
설정 — 받을 주소 등록
PUT
https://api.bootpay.co.kr/v2/reseller/webhookBasic Auth (리셀러 계정 키)| 파라미터 | 타입 | 필수 | 설명 |
|---|---|---|---|
url |
String | 필수 | 받을 주소. 500자 이하 https://, 외부에서 접속할 수 있는 주소만. 빈 값이면 웹훅을 끈다 |
events |
Array | 선택 | 받을 이벤트 이름(아래 이벤트 표). 비우면 전부 받는다 |
rotate_secret |
Boolean | 선택 | true 면 서명 비밀을 새로 만든다. 그 뒤 보내는 웹훅부터 새 비밀로 서명한다 |
{
"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 응답에만 있다. 다시 볼 수 없으니 바로 안전한 곳에 보관한다 |
설정 조회는 GET https://api.bootpay.co.kr/v2/reseller/webhook — 같은 모양에서 secret 만 빠진다.
이벤트
| event | 언제 | 지금 |
|---|---|---|
seller.onboarding.changed |
하위 판매자의 PG 신청이 접수되거나 상태가 바뀔 때(메모만 남긴 변경은 보내지 않는다) | 보냄 |
seller.activated |
PG 승인 뒤 MID 가 하위 프로젝트에 연결됐을 때 | 보냄 |
seller.agreement.required |
약관 새 버전이 시행돼 다시 동의가 필요할 때 | 예정 |
seller.created |
하위 가맹점이 만들어졌을 때 | 예정 |
seller.commerce.failed |
하위 프로젝트의 커머스 연동이 실패했을 때 | 예정 |
seller.settle_blocked |
하위 정산이 막혔을 때 | 예정 |
판매자가 부트페이 콘솔에서 직접 낸 신청이라도 그 가맹점이 리셀러 하위이면 상태 변경 웹훅이 온다.
받는 요청
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 |
서명 검증
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)python재시도
| 상황 | 동작 |
|---|---|
HTTP 200 + {"success": true} |
성공 — 더 보내지 않는다 |
그 밖의 응답 코드, success 가 없거나 false |
다시 보낸다 |
| 연결 3초·응답 5초 안에 끝나지 않음 | 다시 보낸다 |
| 10번 보내도 실패 | 멈추고 리셀러 담당자에게 알린다 |
| 주소가 내부·사설 IP 로 풀리거나 서명 비밀이 없음 | 보내지 않는다 |
다시 보낼 때도 id·본문은 처음과 같다. 서명(t·v1)은 보낼 때마다 새로 계산한다. 시간이 오래 걸리는 처리는 먼저 200 으로 답하고 뒤에서 한다.
에러 코드 (설정 API)
| 코드 | 메시지 | 대처 방법 |
|---|---|---|
API_ONLY_RESELLER (601) |
리셀러만 이용이 가능한 API 다 | 리셀러 권한 계정 키로 호출한다 |
RESELLER_SUSPENDED (343) |
본사가 리셀러 사용을 멈췄다 (HTTP 403) | 부트페이에 문의한다 |
RESELLER_WEBHOOK_URL_INVALID (8130) |
웹훅 주소 형식이 맞지 않다 | 500자 이하의 https:// 주소로 보낸다 |
RESELLER_WEBHOOK_URL_FORBIDDEN (8131) |
내부·사설 주소로는 보낼 수 없다 | 외부에서 접속할 수 있는 주소를 넣는다 |
RESELLER_WEBHOOK_EVENT_INVALID (8132) |
받을 수 없는 이벤트가 들어 있다 | 위 이벤트 표의 이름만 보낸다 |


