본문 바로가기
빌링플러스개발자센터
홈 로그인 도입 상담

웹훅

웹훅 받기

웹훅 봉투·서명 검증·재시도·멱등 처리와 이벤트 목록, 이벤트별 본문 예시.

웹훅은 결제 결과나 정보 변경이 발생했을 때, 등록한 시스템으로 소식을 보내주는 기능입니다.

예를 들어 매달 자동결제가 승인되면 빌링플러스가 가맹점 서버의 주소로 "결제가 승인됐다" 는 요청을 보냅니다. 가맹점 서버는 그 요청을 받아 이용권을 늘리는 등 필요한 일을 합니다.

설정

  • 빌링플러스 화면의 연동관리 > 웹훅 설정에서 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-Typeapplication/json; charset=utf-8본문은 UTF-8 JSON 이다.
X-BillingPlus-Eventpayment.approved이벤트 이름. 봉투의 event 와 같다.
X-BillingPlus-Event-Id9f1c2b4e-…이벤트 ID. 재시도·재발송에도 바뀌지 않는다 — 멱등 처리 키.
X-BillingPlus-Timestamp1789430272서명에 쓴 시각(초). ±5분 밖이면 거부해도 된다.
X-BillingPlus-Signaturehex(64자)HMAC-SHA256(시크릿, "타임스탬프.본문").
  • 코드값 옆의 *_label 은 한글 표시용입니다. 분기는 코드값으로 하세요 — 라벨 문구는 바뀔 수 있습니다.
  • 필드는 추가만 됩니다. 모르는 필드는 무시하고, 이벤트 종류도 늘어날 수 있으니 모르는 이벤트는 2xx 로 답하고 건너뛰세요.
  • 카드번호·빌링키 원문·회원 연락처와 주소 값은 어떤 이벤트에도 담기지 않습니다. 필요한 값은 API 로 다시 조회합니다.

서명 검증

HMAC-SHA256(웹훅 시크릿, 타임스탬프 + "." + 본문) 을 계산해 X-BillingPlus-Signature 와 비교합니다. 타임스탬프는 X-BillingPlus-Timestamp 값, 본문은 받은 바이트 그대로입니다. 아래 예제는 빌링플러스가 보내는 서명과 같은 값을 계산하는 것을 실제로 실행해 확인했습니다.

Python
# 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 로 답합니다
Node.js
// 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 예시 — 원문은 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
// 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
"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 — 결제 승인

카드 승인이 완료됐을 때. 정기·수시·청구서결제 모두.

data
{
  "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 은 빌링플러스 표준 사유다(결제사 코드가 아니다).

data
{
  "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 로 남아 있을 수 있다).

data
{
  "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 로 성공/실패를 가른다.

data
{
  "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(직접수납)를 가른다.

data
{
  "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 도 함께 나간다.

data
{
  "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)다.

data
{
  "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 — 미납 손실 처리

담당자가 받기를 포기(제각)했을 때. 가맹점 시스템의 미수 목록에서 빼야 한다.

data
{
  "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·등록 링크·등록창 모두).

data
{
  "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 에 바뀐 항목 이름이 온다.

data
{
  "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 — 정기결제 일시정지

재청구를 다 써서 멈췄거나 담당자가 정지했을 때. 다음 달부터 청구가 나가지 않는다.

data
{
  "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 — 계약 해지

계약이 중도 해지됐을 때.

data
{
  "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 — 계약 약정 만료

약정 기간이 끝나 정상 종료됐을 때(해지가 아니다).

data
{
  "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.id 로 POST /api/v1/charge-consents/{id}/charges 를 불러 결제한다. 한 번에 limit_once, 한 달에 limit_month 를 넘는 요청은 거절된다.

data
{
  "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": ""
}

동의가 해지됐을 때. 이후 이 동의로는 결제할 수 없다(422 CONSENT_REVOKED).

data
{
  "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} 로 읽는다.

data
{
  "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 를 보고 다시 읽는다. 연락처·주소 값은 싣지 않는다.

data
{
  "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 — 회원 해지

회원이 해지됐을 때.

data
{
  "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 는 담당자·고객사가 붙인 요금제 코드(없으면 빈 문자열).

data
{
  "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회성 카드는 알리지 않는다.

data
{
  "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 — 결제수단 폐기

회원 카드가 폐기됐을 때.

data
{
  "customer_code": "M-0001",
  "provider": "HYOSUNG",
  "card_masked": "123456******3456",
  "label": "회원 카드"
}

점검

웹훅 설정 화면에서 보내는 테스트 발송.

webhook.test — 테스트 발송

가맹점이 웹훅 설정 화면에서 보낸 점검용. 결제 데이터가 없다. 2xx 로 응답하면 된다.

data
{
  "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 절에도 같은 이벤트·헤더·예시가 실려 있습니다.