마켓플레이스

리셀러 웹훅

판매자의 신청이 어디까지 왔는지 리셀러 서버가 바로 안다.

하위 판매자의 PG 신청 단계가 바뀌거나 MID 연결이 끝나면, 부트페이가 리셀러가 등록한 주소로 서명된 POST 를 보낸다. 결제 완료를 알리는 가맹점 웹훅 과는 다른 통로이고 모양·서명도 다르다.

핵심 요약

  • 받을 주소와 이벤트는 PUT /v2/reseller/webhook 으로 등록한다. 서명 비밀(secret)은 처음 설정할 때와 교체할 때 한 번만 응답에 나온다.
  • 모든 요청에 X-Bootpay-Signature 가 붙는다. 받은 본문 그대로(파싱하기 전 바이트)로 서명을 다시 계산해 맞는지 본다.
  • 처리했으면 HTTP 200 과 {"success": true} 로 답한다. 그 밖의 응답·시간 초과는 다시 보낸다(최대 10번).
  • 같은 이벤트가 두 번 올 수 있고 순서가 뒤바뀔 수 있다 → X-Bootpay-Event-Id 로 중복을 거르고, 최신 상태는 등록 상태 조회 로 맞춘다.

설정 — 받을 주소 등록

PUThttps://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 값이다.

  1. 헤더에서 t 와 v1 을 꺼낸다.
  2. 받은 본문 바이트 그대로​(JSON 파싱·재직렬화하지 않은 원문)로 "<t>.<본문>" 을 만든다.
  3. 보관한 서명 비밀로 HMAC-SHA256 을 계산해 v1 과 시간 일정 비교로 맞춘다.
  4. 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 })
})javascript

재시도

상황 동작
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) 받을 수 없는 이벤트가 들어 있다 위 이벤트 표의 이름만 보낸다

함께 보기