웹훅
웹훅 받기
웹훅 봉투·서명 검증·재시도·멱등 처리와 이벤트 목록, 이벤트별 본문 예시.
웹훅은 결제 결과나 정보 변경이 발생했을 때, 등록한 시스템으로 소식을 보내주는 기능입니다.
예를 들어 매달 자동결제가 승인되면 빌링플러스가 가맹점 서버의 주소로 "결제가 승인됐다" 는 요청을 보냅니다. 가맹점 서버는 그 요청을 받아 이용권을 늘리는 등 필요한 일을 합니다.
설정
- 빌링플러스 화면의 연동관리 > 웹훅 설정에서 API 키마다 수신 주소(URL) 하나를 등록합니다. 받을 이벤트도 키마다 고릅니다(아무것도 고르지 않으면 전부 받습니다).
- 주소를 처음 등록하면 웹훅 시크릿이 만들어집니다. 그 화면에서 한 번만 보이니 서버 환경변수에 저장하세요. API 시크릿 키와는 다른 값입니다.
- 수신 주소는 https 이고 인터넷에서 접근할 수 있는 공개 주소여야 합니다. 내부망·사설 IP 주소로는 보내지 않습니다.
- 리다이렉트(3xx)를 따라가지 않습니다. 3xx 응답은 실패로 보고 다시 보냅니다. 최종 주소를 그대로 등록하세요.
- [테스트 발송]을 누르면
webhook.test이벤트가 바로 나가고 결과가 화면에 보입니다. 테스트는 다시 보내지 않습니다. - 보낸 이력·응답 코드·시도 기록은 연동관리 > 웹훅 이력에서 보고, 실패한 건을 다시 보낼 수 있습니다.
요청 형식
모든 이벤트는 POST 로, 아래 봉투에 담겨 옵니다. data 안이 이벤트별 본문입니다.
{
"event_id": "9f1c2b4e-6d2a-4c1e-9a7b-0d3e5f6a7b8c",
"event": "payment.approved",
"occurred_at": "2026-09-15T10:31:12+09:00",
"data": {
"...": "이벤트별 본문(아래 목록)"
}
}| 헤더 | 예 | 설명 |
|---|---|---|
Content-Type | application/json; charset=utf-8 | 본문은 UTF-8 JSON 이다. |
X-BillingPlus-Event | payment.approved | 이벤트 이름. 봉투의 event 와 같다. |
X-BillingPlus-Event-Id | 9f1c2b4e-… | 이벤트 ID. 재시도·재발송에도 바뀌지 않는다 — 멱등 처리 키. |
X-BillingPlus-Timestamp | 1789430272 | 서명에 쓴 시각(초). ±5분 밖이면 거부해도 된다. |
X-BillingPlus-Signature | hex(64자) | HMAC-SHA256(시크릿, "타임스탬프.본문"). |
- 코드값 옆의
*_label은 한글 표시용입니다. 분기는 코드값으로 하세요 — 라벨 문구는 바뀔 수 있습니다. - 필드는 추가만 됩니다. 모르는 필드는 무시하고, 이벤트 종류도 늘어날 수 있으니 모르는 이벤트는 2xx 로 답하고 건너뛰세요.
- 카드번호·빌링키 원문·회원 연락처와 주소 값은 어떤 이벤트에도 담기지 않습니다. 필요한 값은 API 로 다시 조회합니다.
서명 검증
HMAC-SHA256(웹훅 시크릿, 타임스탬프 + "." + 본문) 을 계산해 X-BillingPlus-Signature 와 비교합니다. 타임스탬프는 X-BillingPlus-Timestamp 값, 본문은 받은 바이트 그대로입니다. 아래 예제는 빌링플러스가 보내는 서명과 같은 값을 계산하는 것을 실제로 실행해 확인했습니다.
# Flask 예시 — 다른 프레임워크도 "받은 본문 바이트 그대로" 를 쓰는 것만 같으면 됩니다.
import hashlib
import hmac
import json
import os
import time
from flask import Flask, request
app = Flask(__name__)
WEBHOOK_SECRET = os.environ["BILLINGPLUS_WEBHOOK_SECRET"] # 웹훅 설정에서 받은 시크릿
TOLERANCE_SEC = 300
def verify_webhook(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode("utf-8"), timestamp.encode("ascii") + b"." + raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode("ascii"), signature.encode("utf-8"))
@app.post("/billingplus/webhook")
def billingplus_webhook():
raw = request.get_data() # 파싱하기 전 원문 바이트
ts = request.headers.get("X-BillingPlus-Timestamp", "")
sig = request.headers.get("X-BillingPlus-Signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > TOLERANCE_SEC:
return "", 400
if not verify_webhook(raw, ts, sig, WEBHOOK_SECRET):
return "", 400
event = json.loads(raw)
if already_processed(event["event_id"]): # 중복 도착 — 처리하지 않고 2xx
return "", 200
enqueue(event) # 오래 걸리는 일은 큐로 넘기고
return "", 200 # 먼저 2xx 로 답합니다// Express 예시 — 이 경로는 express.json() 대신 express.raw() 로 원문 바이트를 받습니다.
const crypto = require('node:crypto');
const express = require('express');
const app = express();
const WEBHOOK_SECRET = process.env.BILLINGPLUS_WEBHOOK_SECRET; // 웹훅 설정에서 받은 시크릿
const TOLERANCE_SEC = 300;
function verifyWebhook(rawBody, timestamp, signature, secret) {
const expected = crypto.createHmac('sha256', secret)
.update(`${timestamp}.`, 'utf8').update(rawBody).digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(String(signature || ''), 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b); // 길이가 다르면 timingSafeEqual 이 예외를 냅니다
}
app.post('/billingplus/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('X-BillingPlus-Timestamp') || '';
const sig = req.get('X-BillingPlus-Signature') || '';
if (!/^\d+$/.test(ts) || Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SEC) {
return res.sendStatus(400);
}
if (!verifyWebhook(req.body, ts, sig, WEBHOOK_SECRET)) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8'));
if (alreadyProcessed(event.event_id)) return res.sendStatus(200); // 중복 도착
enqueue(event); // 오래 걸리는 일은 큐로
return res.sendStatus(200);
});<?php
// 순수 PHP 예시 — 원문은 php://input 에서 읽습니다($_POST 가 아닙니다).
const TOLERANCE_SEC = 300;
function bp_verify_webhook(string $rawBody, string $timestamp, string $signature, string $secret): bool {
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_BILLINGPLUS_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_BILLINGPLUS_SIGNATURE'] ?? '';
if (!ctype_digit($ts) || abs(time() - (int) $ts) > TOLERANCE_SEC
|| !bp_verify_webhook($raw, $ts, $sig, getenv('BILLINGPLUS_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
if (!already_processed($event['event_id'])) { // 중복 도착이면 건너뜁니다
enqueue($event); // 오래 걸리는 일은 큐로
}
http_response_code(200);// Java 17 이상 — 프레임워크에서 본문을 문자열·객체로 바꾸기 전의 byte[] 를 넘깁니다.
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class BillingPlusWebhook {
static final long TOLERANCE_SEC = 300;
static boolean verify(byte[] rawBody, String timestamp, String signature, String secret)
throws Exception {
if (timestamp == null || !timestamp.matches("\\d+")
|| Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(timestamp)) > TOLERANCE_SEC) {
return false;
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
String expected = HexFormat.of().formatHex(mac.doFinal(rawBody));
return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
(signature == null ? "" : signature).getBytes(StandardCharsets.UTF_8));
}
// Spring 예:
// @PostMapping(value = "/billingplus/webhook", consumes = "application/json")
// ResponseEntity<Void> receive(@RequestBody byte[] raw,
// @RequestHeader("X-BillingPlus-Timestamp") String ts,
// @RequestHeader("X-BillingPlus-Signature") String sig) throws Exception {
// if (!verify(raw, ts, sig, System.getenv("BILLINGPLUS_WEBHOOK_SECRET"))) {
// return ResponseEntity.badRequest().build();
// }
// // event_id 로 중복 확인 → 큐에 넣고 바로 200
// return ResponseEntity.ok().build();
// }
}JSON 으로 파싱한 뒤 다시 직렬화한 문자열로는 서명이 맞지 않습니다. 공백·키 순서·한글 표기가 달라지기 때문입니다. 프레임워크가 본문을 자동으로 파싱한다면 원문 바이트를 따로 받는 설정을 쓰세요.
비교는 == 대신 상수 시간 비교(hmac.compare_digest · crypto.timingSafeEqual · hash_equals · MessageDigest.isEqual)를 씁니다.
타임스탬프가 ±5분 밖이면 거부해도 됩니다. 다시 보낼 때마다 그 시각으로 새로 서명하므로 정상 재시도는 막히지 않습니다.
응답과 재시도
- 2xx 로 응답하면 전달 완료입니다. 응답 본문은 보지 않습니다.
- 연결 3초, 응답 10초를 넘기면 실패로 봅니다. 처리에 시간이 걸리면 먼저 2xx 를 돌려주고 나머지는 큐에서 처리하세요.
- 2xx 가 아니면(3xx·4xx·5xx·시간 초과 포함) 최대 8회 다시 보냅니다. 간격(분): 1 → 5 → 15 → 30 → 60 → 120 → 240 → 480. 첫 발송부터 마지막 재시도까지 약 15시간 51분입니다.
- 재시도를 다 쓰면 '포기' 로 남습니다. 서버를 고친 뒤 웹훅 이력에서 다시 보내면 됩니다.
- 다시 보낼 때는 그때의 설정을 씁니다. 수신 주소를 바꾸면 밀린 재시도도 새 주소로 가고, 주소를 비우거나 키를 폐기하면 보내지 않습니다.
중복과 순서
순서를 보장하지 않고, 같은 이벤트가 두 번 이상 도착할 수 있습니다. 네트워크 사정이나 재시도 때문입니다.
event_id로 한 번만 처리하세요. 재시도·수동 재발송에도event_id는 바뀌지 않습니다(헤더X-BillingPlus-Event-Id와 같은 값).- 처리한
event_id를 유일값 칸에 저장하고, 이미 있으면 처리 없이 2xx 로 답합니다. - 순서는
occurred_at으로 판단하거나, 확실한 최신 상태가 필요하면 API 로 다시 조회합니다(예:GET /api/v1/payments/{id}).
1. 서명·타임스탬프 확인 — 틀리면 400
2. event_id 를 처리 기록 표에 넣기 시도 — 이미 있으면 200 으로 끝
3. 이벤트를 큐에 넣고 200
4. (큐) event 종류별로 처리. 상태를 덮어쓸 때는 occurred_at 이 더 최신인지 확인변경 경로(source)와 되울림 방지
회원·계약·결제수단 등록 이벤트에는 source 블록이 실립니다. 그 변경이 어디서 왔는지입니다.
"source": {"kind": "API", "api_key": "bpk_live_a…1f2e", "api_key_label": "홈페이지"}| kind | 뜻 |
|---|---|
SCREEN | 가맹점 담당자가 빌링플러스 화면에서 바꿨다. |
API | 가맹점 시스템이 공개 API 로 바꿨다. api_key_label 이 그 키의 용도다 — 자기 키면 무시한다. |
MEMBER | 회원 본인이 등록 링크·등록창·청구서 링크에서 바꿨다. |
CSV | 담당자가 CSV 일괄 등록으로 바꿨다. |
SYSTEM | 배치·자동 처리(재청구 소진 정지, 약정 만료 등). |
자기 키로 바꾼 통보는 무시하세요. 가맹점 시스템이 API 로 회원을 고치면 그 변경도 웹훅으로 다시 옵니다(kind 가 API, api_key_label 이 그 키의 용도). 그것을 또 반영하면 양쪽이 서로 덮어씁니다.
이벤트 목록
| 묶음 | 이벤트 | 이름 |
|---|---|---|
| 결제 | payment.approved | 결제 승인 |
payment.failed | 결제 실패 | |
payment.canceled | 결제 취소 | |
subscription.charged | 정기결제 청구 | |
| 청구·수납 | invoice.paid | 청구서 수납 |
invoice.direct_receipt | 직접수납 기록 | |
invoice.direct_receipt_canceled | 직접수납 취소 | |
invoice.written_off | 미납 손실 처리 | |
| 계약 | subscription.created | 계약 등록 |
subscription.updated | 계약 변경 | |
subscription.paused | 정기결제 일시정지 | |
subscription.canceled | 계약 해지 | |
subscription.completed | 계약 약정 만료 | |
charge_consent.created | 수시결제 동의 등록 | |
charge_consent.revoked | 수시결제 동의 해지 | |
| 회원 | customer.created | 회원 등록 |
customer.updated | 회원 정보 변경 | |
customer.closed | 회원 해지 | |
signup.completed | 가입 링크 가입 완료 | |
| 결제수단 | billing_key.registered | 결제수단 등록 |
billing_key.deleted | 결제수단 폐기 | |
| 점검 | webhook.test | 테스트 발송 |
이벤트별 본문(data) 예시
예시 값은 설명을 위해 만든 값입니다. 각 예시는 실제로 보내는 본문과 필드가 같은지 자동으로 확인하고 있습니다.
결제
승인·실패·취소. 정산 시스템이 받는다.
payment.approved — 결제 승인
카드 승인이 완료됐을 때. 정기·수시·청구서결제 모두.
{
"payment_no": "P-2026-0912-0001",
"status": "APPROVED",
"status_label": "승인완료",
"amount": 29900,
"canceled_amount": 0,
"failure_reason": null,
"failure_reason_label": null,
"approved_at": "2026-09-10T10:00:41+09:00",
"canceled_at": null,
"requested_at": "2026-09-10T10:00:39+09:00",
"customer_code": "M-0001",
"customer_name": "홍길동",
"invoice_no": "I-2026-0912-0001",
"billing_type": "RECURRING",
"billing_type_label": "정기빌링",
"billing_month": "2026-09",
"billing_round": 7,
"contract_no": "C-2026-0001",
"charge_consent_no": null,
"item_name": "월 회비",
"channel": "BATCH",
"channel_label": "자동",
"executor": "자동 배치",
"attempt_no": 1,
"is_retry": false,
"card_masked": "123456******3456",
"provider": "HYOSUNG",
"method_kind": "BILLING_KEY",
"method_kind_label": "등록카드",
"installment_months": 0
}payment.failed — 결제 실패
결제사가 거절했을 때. failure_reason 은 빌링플러스 표준 사유다(결제사 코드가 아니다).
{
"payment_no": "P-2026-0912-0001",
"status": "FAILED",
"status_label": "승인실패",
"amount": 29900,
"canceled_amount": 0,
"failure_reason": "LIMIT_EXCEEDED",
"failure_reason_label": "한도초과",
"approved_at": null,
"canceled_at": null,
"requested_at": "2026-09-10T10:00:39+09:00",
"customer_code": "M-0001",
"customer_name": "홍길동",
"invoice_no": "I-2026-0912-0001",
"billing_type": "RECURRING",
"billing_type_label": "정기빌링",
"billing_month": "2026-09",
"billing_round": 7,
"contract_no": "C-2026-0001",
"charge_consent_no": null,
"item_name": "월 회비",
"channel": "BATCH",
"channel_label": "자동",
"executor": "자동 배치",
"attempt_no": 1,
"is_retry": false,
"card_masked": "123456******3456",
"provider": "HYOSUNG",
"method_kind": "BILLING_KEY",
"method_kind_label": "등록카드",
"installment_months": 0
}payment.canceled — 결제 취소
결제가 취소됐을 때. 부분취소는 하지 않으므로 status 는 CANCELED 다(정책 이전에 부분취소된 결제만 PARTIAL 로 남아 있을 수 있다).
{
"payment_no": "P-2026-0912-0001",
"status": "CANCELED",
"status_label": "취소완료",
"amount": 29900,
"canceled_amount": 29900,
"failure_reason": null,
"failure_reason_label": null,
"approved_at": "2026-09-10T10:00:41+09:00",
"canceled_at": "2026-09-11T14:20:05+09:00",
"requested_at": "2026-09-10T10:00:39+09:00",
"customer_code": "M-0001",
"customer_name": "홍길동",
"invoice_no": "I-2026-0912-0001",
"billing_type": "RECURRING",
"billing_type_label": "정기빌링",
"billing_month": "2026-09",
"billing_round": 7,
"contract_no": "C-2026-0001",
"charge_consent_no": null,
"item_name": "월 회비",
"channel": "MANUAL",
"channel_label": "즉시",
"executor": "김담당",
"attempt_no": 1,
"is_retry": false,
"card_masked": "123456******3456",
"provider": "HYOSUNG",
"method_kind": "BILLING_KEY",
"method_kind_label": "등록카드",
"installment_months": 0
}subscription.charged — 정기결제 청구
정기결제 배치가 청구서를 만들고 승인을 시도한 뒤. result 로 성공/실패를 가른다.
{
"contract_no": "C-2026-0001",
"customer_code": "M-0001",
"customer_name": "홍길동",
"item_name": "월 회비",
"amount": 29900,
"next_billing_date": "2026-10-10",
"subscription": {
"contract_no": "C-2026-0001",
"status": "ACTIVE",
"status_label": "정상",
"billing_cycle": "MONTHLY",
"billing_cycle_label": "월간",
"billing_months": "",
"billing_day": 10,
"item_name": "월 회비",
"amount": 29900,
"amount_mode": "FIXED",
"started_on": "2026-03-10",
"ended_on": "2027-02-10",
"next_billing_date": "2026-10-10"
},
"invoice": {
"invoice_no": "I-2026-0912-0001",
"status": "PAID",
"status_label": "수납완료",
"billing_type": "RECURRING",
"billing_type_label": "정기빌링",
"total_amount": 29900,
"customer_code": "M-0001",
"customer_name": "홍길동",
"billing_month": "2026-09",
"billing_round": 7,
"due_date": "2026-09-10",
"issued_at": "2026-09-10T10:00:03+09:00",
"paid_at": "2026-09-10T10:00:41+09:00",
"contract_no": "C-2026-0001",
"subscription": {
"contract_no": "C-2026-0001",
"status": "ACTIVE",
"status_label": "정상",
"billing_cycle": "MONTHLY",
"billing_cycle_label": "월간",
"billing_months": "",
"billing_day": 10,
"item_name": "월 회비",
"amount": 29900,
"amount_mode": "FIXED",
"started_on": "2026-03-10",
"ended_on": "2027-02-10",
"next_billing_date": "2026-10-10"
},
"charge_consent_no": null,
"items": [
{
"name": "월 회비",
"quantity": 1,
"unit_price": 29900,
"amount": 29900
}
],
"collected_via": "CARD",
"direct_receipt": null
},
"payment": {
"payment_no": "P-2026-0912-0001",
"status": "APPROVED",
"status_label": "승인완료",
"amount": 29900,
"canceled_amount": 0,
"failure_reason": null,
"failure_reason_label": null,
"approved_at": "2026-09-10T10:00:41+09:00",
"canceled_at": null,
"requested_at": "2026-09-10T10:00:39+09:00",
"customer_code": "M-0001",
"customer_name": "홍길동",
"invoice_no": "I-2026-0912-0001",
"billing_type": "RECURRING",
"billing_type_label": "정기빌링",
"billing_month": "2026-09",
"billing_round": 7,
"contract_no": "C-2026-0001",
"charge_consent_no": null,
"item_name": "월 회비",
"channel": "BATCH",
"channel_label": "자동",
"executor": "자동 배치",
"attempt_no": 1,
"is_retry": false,
"card_masked": "123456******3456",
"provider": "HYOSUNG",
"method_kind": "BILLING_KEY",
"method_kind_label": "등록카드",
"installment_months": 0
},
"result": "APPROVED"
}청구·수납
청구서 수납과 미수 처리(직접수납·손실 처리).
invoice.paid — 청구서 수납
청구서가 수납 상태가 됐을 때. collected_via 가 CARD(카드) / DIRECT_RECEIPT(직접수납)를 가른다.
{
"invoice_no": "I-2026-0912-0001",
"status": "PAID",
"status_label": "수납완료",
"billing_type": "RECURRING",
"billing_type_label": "정기빌링",
"total_amount": 29900,
"customer_code": "M-0001",
"customer_name": "홍길동",
"billing_month": "2026-09",
"billing_round": 7,
"due_date": "2026-09-10",
"issued_at": "2026-09-10T10:00:03+09:00",
"paid_at": "2026-09-10T10:00:41+09:00",
"contract_no": "C-2026-0001",
"subscription": {
"contract_no": "C-2026-0001",
"status": "ACTIVE",
"status_label": "정상",
"billing_cycle": "MONTHLY",
"billing_cycle_label": "월간",
"billing_months": "",
"billing_day": 10,
"item_name": "월 회비",
"amount": 29900,
"amount_mode": "FIXED",
"started_on": "2026-03-10",
"ended_on": "2027-02-10",
"next_billing_date": "2026-10-10"
},
"charge_consent_no": null,
"items": [
{
"name": "월 회비",
"quantity": 1,
"unit_price": 29900,
"amount": 29900
}
],
"collected_via": "CARD",
"direct_receipt": null
}invoice.direct_receipt — 직접수납 기록
담당자가 현금·무통장으로 받은 돈을 기록했을 때. invoice.paid 도 함께 나간다.
{
"invoice": {
"invoice_no": "I-2026-0912-0001",
"status": "PAID",
"status_label": "수납완료",
"billing_type": "RECURRING",
"billing_type_label": "정기빌링",
"total_amount": 29900,
"customer_code": "M-0001",
"customer_name": "홍길동",
"billing_month": "2026-09",
"billing_round": 7,
"due_date": "2026-09-10",
"issued_at": "2026-09-10T10:00:03+09:00",
"paid_at": "2026-09-14T00:00:00+09:00",
"contract_no": "C-2026-0001",
"subscription": {
"contract_no": "C-2026-0001",
"status": "ACTIVE",
"status_label": "정상",
"billing_cycle": "MONTHLY",
"billing_cycle_label": "월간",
"billing_months": "",
"billing_day": 10,
"item_name": "월 회비",
"amount": 29900,
"amount_mode": "FIXED",
"started_on": "2026-03-10",
"ended_on": "2027-02-10",
"next_billing_date": "2026-10-10"
},
"charge_consent_no": null,
"items": [
{
"name": "월 회비",
"quantity": 1,
"unit_price": 29900,
"amount": 29900
}
],
"collected_via": "DIRECT_RECEIPT",
"direct_receipt": {
"method": "BANK_TRANSFER",
"method_label": "무통장입금",
"amount": 29900,
"received_on": "2026-09-14",
"memo": "9/14 입금 확인",
"canceled_at": null,
"cancel_reason": ""
}
},
"receipt": {
"method": "BANK_TRANSFER",
"method_label": "무통장입금",
"amount": 29900,
"received_on": "2026-09-14",
"memo": "9/14 입금 확인",
"canceled_at": null,
"cancel_reason": ""
}
}invoice.direct_receipt_canceled — 직접수납 취소
잘못 기록한 직접수납을 취소했을 때. 청구서는 다시 미수(CONFIRMED)다.
{
"invoice": {
"invoice_no": "I-2026-0912-0001",
"status": "CONFIRMED",
"status_label": "확정",
"billing_type": "RECURRING",
"billing_type_label": "정기빌링",
"total_amount": 29900,
"customer_code": "M-0001",
"customer_name": "홍길동",
"billing_month": "2026-09",
"billing_round": 7,
"due_date": "2026-09-10",
"issued_at": "2026-09-10T10:00:03+09:00",
"paid_at": null,
"contract_no": "C-2026-0001",
"subscription": {
"contract_no": "C-2026-0001",
"status": "ACTIVE",
"status_label": "정상",
"billing_cycle": "MONTHLY",
"billing_cycle_label": "월간",
"billing_months": "",
"billing_day": 10,
"item_name": "월 회비",
"amount": 29900,
"amount_mode": "FIXED",
"started_on": "2026-03-10",
"ended_on": "2027-02-10",
"next_billing_date": "2026-10-10"
},
"charge_consent_no": null,
"items": [
{
"name": "월 회비",
"quantity": 1,
"unit_price": 29900,
"amount": 29900
}
],
"collected_via": null,
"direct_receipt": null
},
"receipt": {
"method": "BANK_TRANSFER",
"method_label": "무통장입금",
"amount": 29900,
"received_on": "2026-09-14",
"memo": "9/14 입금 확인",
"canceled_at": "2026-09-15T09:12:00+09:00",
"cancel_reason": "다른 회원 입금을 잘못 기록"
}
}invoice.written_off — 미납 손실 처리
담당자가 받기를 포기(제각)했을 때. 가맹점 시스템의 미수 목록에서 빼야 한다.
{
"invoice": {
"invoice_no": "I-2026-0912-0001",
"status": "VOIDED",
"status_label": "무효",
"billing_type": "RECURRING",
"billing_type_label": "정기빌링",
"total_amount": 29900,
"customer_code": "M-0001",
"customer_name": "홍길동",
"billing_month": "2026-09",
"billing_round": 7,
"due_date": "2026-09-10",
"issued_at": "2026-09-10T10:00:03+09:00",
"paid_at": null,
"contract_no": "C-2026-0001",
"subscription": {
"contract_no": "C-2026-0001",
"status": "ACTIVE",
"status_label": "정상",
"billing_cycle": "MONTHLY",
"billing_cycle_label": "월간",
"billing_months": "",
"billing_day": 10,
"item_name": "월 회비",
"amount": 29900,
"amount_mode": "FIXED",
"started_on": "2026-03-10",
"ended_on": "2027-02-10",
"next_billing_date": "2026-10-10"
},
"charge_consent_no": null,
"items": [
{
"name": "월 회비",
"quantity": 1,
"unit_price": 29900,
"amount": 29900
}
],
"collected_via": null,
"direct_receipt": null
},
"reason": "회원 연락 두절",
"written_off_at": "2026-09-15T11:05:30+09:00"
}계약
정기결제 계약의 등록·변경·정지·해지·만료와 수시결제 동의의 등록·해지.
subscription.created — 계약 등록
정기결제 계약이 만들어졌을 때(화면·API·등록 링크·등록창 모두).
{
"contract_no": "C-2026-0001",
"customer_code": "M-0001",
"customer_name": "홍길동",
"subscription": {
"contract_no": "C-2026-0001",
"status": "ACTIVE",
"status_label": "정상",
"billing_cycle": "MONTHLY",
"billing_cycle_label": "월간",
"billing_months": "",
"billing_day": 10,
"item_name": "월 회비",
"amount": 29900,
"amount_mode": "FIXED",
"started_on": "2026-03-10",
"ended_on": "2027-02-10",
"next_billing_date": "2026-10-10"
},
"cancel_reason": "",
"changed_fields": [],
"updated_at": "2026-03-10T15:02:11+09:00",
"source": {
"kind": "MEMBER",
"api_key": "",
"api_key_label": ""
}
}subscription.updated — 계약 변경
금액·청구일·결제수단 등이 바뀌었을 때. changed_fields 에 바뀐 항목 이름이 온다.
{
"contract_no": "C-2026-0001",
"customer_code": "M-0001",
"customer_name": "홍길동",
"subscription": {
"contract_no": "C-2026-0001",
"status": "ACTIVE",
"status_label": "정상",
"billing_cycle": "MONTHLY",
"billing_cycle_label": "월간",
"billing_months": "",
"billing_day": 10,
"item_name": "월 회비",
"amount": 39900,
"amount_mode": "FIXED",
"started_on": "2026-03-10",
"ended_on": "2027-02-10",
"next_billing_date": "2026-10-10"
},
"cancel_reason": "",
"changed_fields": [
"amount"
],
"updated_at": "2026-09-15T13:40:00+09:00",
"source": {
"kind": "SCREEN",
"api_key": "",
"api_key_label": ""
}
}subscription.paused — 정기결제 일시정지
재청구를 다 써서 멈췄거나 담당자가 정지했을 때. 다음 달부터 청구가 나가지 않는다.
{
"contract_no": "C-2026-0001",
"customer_code": "M-0001",
"customer_name": "홍길동",
"item_name": "월 회비",
"amount": 29900,
"status": "PAUSED",
"status_label": "일시정지",
"reason": "재청구 3회 실패로 정지되었습니다.",
"next_billing_date": "2026-10-10",
"subscription": {
"contract_no": "C-2026-0001",
"status": "PAUSED",
"status_label": "일시정지",
"billing_cycle": "MONTHLY",
"billing_cycle_label": "월간",
"billing_months": "",
"billing_day": 10,
"item_name": "월 회비",
"amount": 29900,
"amount_mode": "FIXED",
"started_on": "2026-03-10",
"ended_on": "2027-02-10",
"next_billing_date": "2026-10-10"
}
}subscription.canceled — 계약 해지
계약이 중도 해지됐을 때.
{
"contract_no": "C-2026-0001",
"customer_code": "M-0001",
"customer_name": "홍길동",
"subscription": {
"contract_no": "C-2026-0001",
"status": "CANCELED",
"status_label": "해지",
"billing_cycle": "MONTHLY",
"billing_cycle_label": "월간",
"billing_months": "",
"billing_day": 10,
"item_name": "월 회비",
"amount": 29900,
"amount_mode": "FIXED",
"started_on": "2026-03-10",
"ended_on": "2027-02-10",
"next_billing_date": null
},
"cancel_reason": "회원 요청",
"changed_fields": [
"status",
"next_billing_date"
],
"updated_at": "2026-09-15T16:00:00+09:00",
"source": {
"kind": "SCREEN",
"api_key": "",
"api_key_label": ""
}
}subscription.completed — 계약 약정 만료
약정 기간이 끝나 정상 종료됐을 때(해지가 아니다).
{
"contract_no": "C-2026-0001",
"customer_code": "M-0001",
"customer_name": "홍길동",
"subscription": {
"contract_no": "C-2026-0001",
"status": "COMPLETED",
"status_label": "약정 만료",
"billing_cycle": "MONTHLY",
"billing_cycle_label": "월간",
"billing_months": "",
"billing_day": 10,
"item_name": "월 회비",
"amount": 29900,
"amount_mode": "FIXED",
"started_on": "2026-03-10",
"ended_on": "2027-02-10",
"next_billing_date": null
},
"cancel_reason": "",
"changed_fields": [
"status",
"next_billing_date"
],
"updated_at": "2027-02-10T10:00:00+09:00",
"source": {
"kind": "SYSTEM",
"api_key": "",
"api_key_label": ""
}
}charge_consent.created — 수시결제 동의 등록
회원이 '필요할 때 결제' 에 동의하고 카드를 등록했을 때(등록 링크·등록창·가입 링크·담당자 대행). charge_consent.id 로 POST /api/v1/charge-consents/{id}/charges 를 불러 결제한다. 한 번에 limit_once, 한 달에 limit_month 를 넘는 요청은 거절된다.
{
"customer_id": 1024,
"customer_code": "M-0001",
"customer_name": "홍길동",
"external_ref": "user-1024",
"charge_consent": {
"id": 77,
"consent_no": "Q26100001",
"status": "ACTIVE",
"status_label": "사용중",
"item_name": "이용 요금",
"limit_once": 50000,
"limit_month": 200000,
"card_masked": "123456******3456",
"source": "WINDOW",
"source_label": "등록창",
"agreed_at": "2026-10-06T14:05:00+09:00"
},
"revoked_at": null,
"revoke_reason": ""
}charge_consent.revoked — 수시결제 동의 해지
동의가 해지됐을 때. 이후 이 동의로는 결제할 수 없다(422 CONSENT_REVOKED).
{
"customer_id": 1024,
"customer_code": "M-0001",
"customer_name": "홍길동",
"external_ref": "user-1024",
"charge_consent": {
"id": 77,
"consent_no": "Q26100001",
"status": "REVOKED",
"status_label": "해지",
"item_name": "이용 요금",
"limit_once": 50000,
"limit_month": 200000,
"card_masked": "123456******3456",
"source": "WINDOW",
"source_label": "등록창",
"agreed_at": "2026-10-06T14:05:00+09:00"
},
"revoked_at": "2026-10-20T09:00:00+09:00",
"revoke_reason": "회원 요청"
}회원
회원 등록·정보 변경·해지·가입 링크 가입. 연동 웹사이트의 회원 DB 가 받는다.
customer.created — 회원 등록
회원이 등록됐을 때. 본문은 얇다 — 값은 GET /api/v1/customers/{customer_id} 로 읽는다.
{
"customer_id": 1024,
"customer_code": "M-0001",
"customer_key": "K7Q2M9",
"external_ref": "user-1024",
"name": "홍길동",
"status": "ACTIVE",
"status_label": "정상",
"group": "정회원",
"is_deleted": false,
"changed_fields": [],
"updated_at": "2026-09-15T09:00:00+09:00",
"source": {
"kind": "SCREEN",
"api_key": "",
"api_key_label": ""
}
}customer.updated — 회원 정보 변경
회원 정보가 바뀌었을 때. changed_fields 를 보고 다시 읽는다. 연락처·주소 값은 싣지 않는다.
{
"customer_id": 1024,
"customer_code": "M-0001",
"customer_key": "K7Q2M9",
"external_ref": "user-1024",
"name": "홍길동",
"status": "ACTIVE",
"status_label": "정상",
"group": "정회원",
"is_deleted": false,
"changed_fields": [
"phone_masked",
"address"
],
"updated_at": "2026-09-15T09:30:00+09:00",
"source": {
"kind": "API",
"api_key": "bpk_live_a…1f2e",
"api_key_label": "홈페이지"
}
}customer.closed — 회원 해지
회원이 해지됐을 때.
{
"customer_id": 1024,
"customer_code": "M-0001",
"customer_key": "K7Q2M9",
"external_ref": "user-1024",
"name": "홍길동",
"status": "CLOSED",
"status_label": "해지",
"group": "정회원",
"is_deleted": false,
"changed_fields": [
"status"
],
"updated_at": "2026-09-15T18:00:00+09:00",
"source": {
"kind": "SCREEN",
"api_key": "",
"api_key_label": ""
}
}signup.completed — 가입 링크 가입 완료
공용 가입 링크로 회원가입·카드 등록·계약이 끝났을 때. external_ref_verified 가 true 일 때만 external_ref 로 자기 회원과 바로 매칭한다(false 는 방문자가 직접 적은 값). terms_mode 는 FIXED(조건 하나)·CHOICES(요금제 선택)·SIGNED(고객사 서버가 조건 전달), plan_code 는 담당자·고객사가 붙인 요금제 코드(없으면 빈 문자열).
{
"signup_link_id": "k3Jx9Q2mTa1b",
"signup_link_name": "홈페이지 월간 구독",
"terms_mode": "CHOICES",
"plan_code": "basic-monthly",
"external_ref": "user-1024",
"external_ref_verified": true,
"is_new_customer": true,
"customer_id": 1024,
"customer_code": "M-0001",
"customer_name": "홍길동",
"contract_no": "C-2026-0001",
"subscription": {
"contract_no": "C-2026-0001",
"status": "ACTIVE",
"status_label": "정상",
"billing_cycle": "MONTHLY",
"billing_cycle_label": "월간",
"billing_months": "",
"billing_day": 10,
"item_name": "월 회비",
"amount": 29900,
"amount_mode": "FIXED",
"started_on": "2026-03-10",
"ended_on": "2027-02-10",
"next_billing_date": "2026-10-10"
},
"billing_mode": "RECURRING",
"charge_consent": null,
"completed_at": "2026-09-21T14:05:00+09:00"
}결제수단
카드 등록·폐기.
billing_key.registered — 결제수단 등록
회원 카드가 등록됐을 때(담당자 대행·등록 링크·등록창). 청구서 링크의 1회성 카드는 알리지 않는다.
{
"payment_method_id": 501,
"customer_code": "M-0001",
"provider": "HYOSUNG",
"card_issuer": "신한카드",
"card_masked": "123456******3456",
"card_type": "신용",
"label": "회원 카드",
"is_default": true,
"source": {
"kind": "MEMBER",
"api_key": "",
"api_key_label": ""
}
}billing_key.deleted — 결제수단 폐기
회원 카드가 폐기됐을 때.
{
"customer_code": "M-0001",
"provider": "HYOSUNG",
"card_masked": "123456******3456",
"label": "회원 카드"
}점검
웹훅 설정 화면에서 보내는 테스트 발송.
webhook.test — 테스트 발송
가맹점이 웹훅 설정 화면에서 보낸 점검용. 결제 데이터가 없다. 2xx 로 응답하면 된다.
{
"message": "빌링플러스 웹훅 수신 점검입니다. 이 요청에 2xx 로 응답하면 정상입니다.",
"credential_label": "홈페이지",
"sample": {
"payment_no": "P-TEST-0000",
"amount": 0,
"status": "APPROVED"
}
}코드값
결제 상태 payment.status
PENDING | 요청중 |
APPROVED | 승인완료 |
PARTIAL | 부분취소 |
CANCELED | 취소완료 |
FAILED | 승인실패 |
UNKNOWN | 미확정 — 결제사 응답을 못 받았다. 조회로 확정될 때까지 성공도 실패도 아니다. 이 상태로는 웹훅이 나가지 않는다 |
실패 사유 payment.failure_reason
LIMIT_EXCEEDED | 한도초과 |
INSUFFICIENT | 잔액부족 |
INVALID_CARD | 카드오류 |
SUSPENDED | 정지·분실카드 |
EXPIRED | 유효기간 경과 |
TEMPORARY | 일시오류(재시도 가치 있음) |
PG_ERROR | 결제사 오류 |
UNMAPPED | 미분류 — 결제사 코드를 아직 분류하지 못함 |
청구유형 billing_type
RECURRING | 정기빌링 |
QUICK | 수시결제 |
INVOICE | 청구서결제 |
실행 구분 payment.channel
BATCH | 자동 — 정기결제 배치·재청구 |
MANUAL | 즉시 — 담당자가 등록카드로 |
KEYIN | 키인 — 담당자가 카드번호로 |
API | 외부연동 — 가맹점 시스템 |
MEMBER | 회원결제 — 회원이 청구서 링크에서 |
결제방식 payment.method_kind
BILLING_KEY | 등록카드(빌링키) |
KEYIN | 키인(카드번호 직접 입력, 저장 안 함) |
청구서 상태 invoice.status
DRAFT | 작성중(웹훅으로 나가지 않는다) |
CONFIRMED | 확정(미수) |
PAID | 수납완료 |
VOIDED | 무효·손실 처리 |
계약 상태 subscription.status
ACTIVE | 정상 |
PAUSED | 일시정지 |
COMPLETED | 약정 만료 |
CANCELED | 해지 |
청구주기 subscription.billing_cycle
MONTHLY | 월간 |
QUARTERLY | 분기 |
HALF_YEARLY | 반기 |
YEARLY | 연간 |
SELECTED | 지정 월 — billing_months 가 "3,6,9,12" 형태 |
직접수납 방법 receipt.method
CASH | 현금 |
BANK_TRANSFER | 무통장입금 |
OTHER | 기타 |
OpenAPI 스펙(API 레퍼런스)의 Webhooks 절에도 같은 이벤트·헤더·예시가 실려 있습니다.
개발자센터