# 웹훅 이벤트

출처 https://www.groble.im/help/guides/webhook-events

전체 가이드 목록 https://www.groble.im/llms.txt

---

## 1\. 이벤트 한눈에 보기

| 이벤트 타입 | 의미 |
| --- | --- |
| `payment.completed` | 일반결제 완료 |
| `payment.cancel_requested` | 일반결제 취소 요청 |
| `payment.refunded` | 일반결제 취소 완료 |
| `subscription_payment.completed` | 정기결제 완료 |
| `subscription_payment.refunded` | 정기결제 취소 완료 (회차 단위) |
| `subscription_payment.failed` | 정기결제 갱신 실패 |
| `subscription.cancel_requested` | 정기결제 해지 요청 |
| `subscription.terminated` | 정기결제 해지 완료 |

### 이름 규칙

-   `subscription_payment.*`는 **회차 결제 1건에 대한 이벤트**입니다: 완료(`completed`) · 취소(`refunded`) · 실패(`failed`).
    
-   `subscription.*`는 **정기결제 자체의 상태 변화**입니다: 해지 요청(`cancel_requested`) · 해지 완료(`terminated`).
    
-   그래서 회차 하나가 취소되어도(`subscription_payment.refunded`) 정기결제는 계속 유지될 수 있고, 정기결제가 끝나는 이벤트는 `subscription.terminated`로 따로 전달됩니다.
    

### 공통 규칙

-   envelope 공통 필드 `id`, `type`, `version`, `occurredAt`은 연동 가이드의 "요청 형식"을 기준으로 봅니다.
    
-   아래 JSON 예시는 테스트 발송 payload 형식 기준입니다. `id`와 시각 값은 발송 시점마다 달라집니다.
    
-   값이 없으면 키를 생략하는 것이 기본입니다.
    
-   금액은 모두 원(KRW) 단위 정수입니다.
    
-   시각은 모두 ISO-8601 KST(`+09:00`)입니다.
    
-   `options[]`는 단일 옵션이어도 항상 배열입니다.
    
-   enum 값은 실제 payload 원문을 그대로 적고, 필요한 곳에는 한글 의미를 함께 풀어 적었습니다.
    
-   표의 `타입`은 수신 파서 기준으로 `string`, `number`, `boolean`, `object`, `array`만 사용합니다. 날짜와 시각 형식은 의미 칸에서 함께 설명합니다.
    
-   필드는 앞으로도 추가될 수 있습니다. 수신 파서는 `data.object`의 모르는 키를 무시하도록(tolerant parsing) 작성해 주세요.
    

### 읽는 방법

-   `payment.completed`를 기준 스키마로 보면 됩니다.
    
-   나머지 7개 이벤트는 각 섹션의 "대비 차이" 표에 적힌 필드만 확인하면 됩니다. 표에 없는 필드는 비교 대상 이벤트와 동일합니다.
    

---

## 2\. `payment.completed` (일반결제 완료)

일반결제 완료 이벤트입니다. 이 문서의 기준 스키마이므로, 다른 이벤트 섹션은 이 payload 대비 차이만 적습니다.

### 필드 명세

**최상위**

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `merchantUid` | `string` | 그로블이 발급한 결제 식별자 |
| `sellerReference` | `string` | 판매자가 전달한 참조값. 전달하지 않았거나 서버에서 폐기된 경우 키 자체가 생략됩니다. 상세 제약은 아래 `sellerReference` 절 참고 |

**buyer**

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `buyer.type` | `string` | `MEMBER`(회원) 또는 `GUEST`(비회원) |
| `buyer.displayName` | `string` | 구매자 표시 이름. 회원은 닉네임, 비회원은 입력 이름 기준. 상품 유형과 관계없이 항상 포함 |
| `buyer.email` | `string` | 구매자 이메일. 상품 유형과 관계없이 항상 포함 |
| `buyer.phoneNumber` | `string` | 구매자 전화번호. 상품 유형과 관계없이 항상 포함 |

**content**

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `content.id` | `string` | 인코딩된 상품 ID |
| `content.title` | `string` | 구매 시점 상품명 |
| `content.type` | `string` | `DOCUMENT`(자료) · `SERVICE`(서비스) · `GOODS`(제품) · `EVENT`(이벤트) |
| `content.paymentType` | `string` | 항상 `ONE_TIME`(일반결제) |
| `content.inputMode` | `string` | 상품 입력 모드<br>`NORMAL`(판매 페이지) · `SIMPLE`(간편 모드) · `PAYMENT_WINDOW`(결제창)<br>간편 모드는 2026년 07월 01일부터 신규 생성이 불가합니다. |

**options\[\]**

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `options[].optionId` | `string` | 인코딩된 옵션 ID |
| `options[].name` | `string` | 옵션명 스냅샷 |
| `options[].description` | `string` | 옵션 설명. 없으면 생략 |
| `options[].quantity` | `number` | 구매 수량 |
| `options[].unitOriginalPrice` | `number` | 할인 전 단가 |
| `options[].unitDiscountedPrice` | `number` | 할인 후 단가 |
| `options[].discount` | `object` | 옵션 할인 블록. 없으면 생략 |
| `options[].discount.type` | `string` | `AMOUNT`(원화 할인) 또는 `PERCENT`(% 할인) |
| `options[].discount.value` | `number` | 할인 값 |
| `options[].discount.timeDeal` | `object` | 타임딜 할인일 때만 포함 |
| `options[].discount.timeDeal.label` | `string` | 타임딜 라벨 |
| `options[].additionalOptions` | `array` | 추가 옵션 배열. 없으면 `[]` |
| `options[].additionalOptions[].id` | `string` | 인코딩된 추가 옵션 ID |
| `options[].additionalOptions[].name` | `string` | 추가 옵션명 |
| `options[].additionalOptions[].quantity` | `number` | 추가 옵션 수량 |
| `options[].additionalOptions[].unitPrice` | `number` | 추가 옵션 단가 |
| `options[].subtotal` | `number` | 옵션 소계 |

**pricing**

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `pricing.currency` | `string` | 항상 `KRW` |
| `pricing.originalAmount` | `number` | 할인 전 총액 |
| `pricing.optionDiscountAmount` | `number` | 옵션/타임딜 할인 합계. 쿠폰 할인 제외 |
| `pricing.shippingFee` | `number` | 배송비. **실물 상품(**`**GOODS**`**) 결제일 때만 포함**되며 `finalAmount`에 이미 더해져 있습니다. 무료배송이면 `0`이 실리고, 배송 개념이 없는 상품 유형은 필드 자체가 생략됩니다 |
| `pricing.finalAmount` | `number` | 실제 결제 금액 |
| `pricing.couponDiscountAmount` | `number` | 쿠폰 할인 금액 |
| `pricing.coupon` | `object` | 쿠폰 블록. 없으면 생략 |
| `pricing.coupon.code` | `string` | 쿠폰 코드 |
| `pricing.coupon.name` | `string` | 쿠폰 이름 |
| `pricing.coupon.type` | `string` | 쿠폰 할인 유형 |
| `pricing.coupon.discountAmount` | `number` | 쿠폰 할인 금액 |

**paymentMethod**

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `paymentMethod.type` | `string` | `CARD`(카드 결제) 또는 `FREE`(무료 결제). `FREE`면 `cardName`·`maskedCardNumber`가 함께 생략됩니다 |
| `paymentMethod.cardName` | `string` | 카드사명 |
| `paymentMethod.maskedCardNumber` | `string` | 마스킹된 카드번호 |

**shipping**

`content.type == GOODS`일 때만 포함됩니다.

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `shipping.address` | `string` | 주소 |
| `shipping.streetAddress` | `string` | 도로명/지번 주소 |
| `shipping.detailAddress` | `string` | 상세 주소 |
| `shipping.deliveryRequest` | `string` | 배송 요청 사항 |

**questionAnswers\[\]**

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `questionAnswers[].question` | `string` | 질문 본문 |
| `questionAnswers[].questionType` | `string` | 질문 유형 |
| `questionAnswers[].required` | `boolean` | 필수 여부 |
| `questionAnswers[].answer` | `string` | 구매자 응답 |
| `questionAnswers[].displayOrder` | `number` | 표시 순서 |

**payment**

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `payment.purchasedAt` | `string` | ISO-8601 KST 형식의 구매 시각 |

**trackingLink**

구매자가 유입 추적링크로 진입한 결제만 포함됩니다. 추적링크 없이 직접 진입한 결제면 `trackingLink` 블록 자체가 생략됩니다.

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `trackingLink.code` | `string` | 유입 추적링크 코드 (예: `x5w7s62u`) |
| `trackingLink.name` | `string` | 유입 추적링크 이름 (판매자 설정값) |

### sellerReference

판매자가 결제창 진입 링크에 `?ref=<value>`를 붙여 값을 지정한 경우에만 포함됩니다. 전달하지 않았거나 아래 제약을 위반해 서버에서 값이 폐기된 경우 키 자체가 생략됩니다.

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `sellerReference` | `string` | 판매자 시스템의 주문/사용자 식별자 등 불투명 참조값 |

**포함되는 이벤트 범위**

-   `sellerReference`는 **결제가 시도된 이벤트와 정기결제 상태 변화 이벤트에** 포함됩니다: `payment.completed`, `subscription_payment.completed`(최초 결제 `INITIAL`뿐 아니라 이후 모든 갱신 `RENEWAL` 회차에도 동일하게 포함), `subscription_payment.failed`, `subscription.cancel_requested`, `subscription.terminated`
    
-   `payment.cancel_requested` · `payment.refunded` · `subscription_payment.refunded`에는 **포함되지 않습니다.**
    
-   이 세 이벤트는 `merchantUid`가 취소·환불되는 그 결제 건과 같은 값이라, 완료 이벤트에서 이미 받아 둔 `merchantUid`로 그대로 매칭하면 됩니다.
    
-   완료 이벤트를 받은 시점에 `merchantUid ↔ sellerReference` 매핑을 판매자 DB에 저장해 두면 됩니다.
    
-   다만 **정기결제는 회차마다** `**merchantUid**`**가 새로 발급**되므로 정기결제 자체를 가리키는 값이 아닙니다. 정기결제 단위로 연결할 때는 회차와 무관하게 동일한 `sellerReference`를 써 주세요.
    

**값 보장**

서버가 값을 수용하면(허용 문자·길이 제약을 모두 통과하면) 저장·반환 과정에서 정규화·대소문자 변환·잘라내기 없이 **원문 바이트 그대로** 저장되고, 웹훅 payload에도 전송한 값과 동일하게 실립니다.

**형식 제약**

-   허용 문자 `[A-Za-z0-9-_.:=~]`, 최대 128자. 사전 검증용 정규식: `^[A-Za-z0-9\-_.:=~]{1,128}$`
    
-   제약을 위반하면 값 전체가 폐기되어 payload에서 키가 생략됩니다. 잘라서 저장하지 않으며, 주문 생성이 실패하지도 않습니다.
    
-   결제는 정상 진행되고 참조값만 조용히 사라지므로 **전송 전 검증은 판매자 쪽 몫**입니다
    

값 설계·인코딩 방법은 결제창 연동 가이드를 참고해 주세요.

**멱등성 · 매칭**

결제 확정 여부는 반드시 서명 검증을 통과한 웹훅으로만 판단해 주세요(리다이렉트로는 절대 판단하지 말아 주세요). 결제 건 매칭·중복 처리 방지(멱등 키)에는 `sellerReference`가 아니라 `merchantUid`를 사용해야 합니다. `sellerReference`는 판매자 시스템과의 매핑용 보조 값일 뿐, 식별자나 멱등 키로 쓰도록 설계되지 않았습니다 (연동 가이드의 "처리 규칙" 참고)

### JSON 예시

#### 펼쳐서 보기 👀

```json
{
  "id": "evt_test_a1b2c3d4e5f60718293a4b5c",
  "type": "payment.completed",
  "version": "2026-04-30",
  "occurredAt": "2026-04-20T14:22:11+09:00",
  "data": {
    "object": {
      "merchantUid": "test_merchant_0001",
      "buyer": {
        "type": "MEMBER",
        "displayName": "테스트 구매자",
        "email": "test@buyer.example",
        "phoneNumber": "01012345678"
      },
      "content": {
        "id": "test_content_0001",
        "title": "테스트 상품",
        "type": "DOCUMENT",
        "paymentType": "ONE_TIME",
        "inputMode": "NORMAL"
      },
      "options": [
        {
          "optionId": "test_option_0001",
          "name": "기본 옵션",
          "description": "테스트용 기본 옵션",
          "quantity": 1,
          "unitOriginalPrice": 10000,
          "unitDiscountedPrice": 10000,
          "additionalOptions": [],
          "subtotal": 10000
        }
      ],
      "pricing": {
        "currency": "KRW",
        "originalAmount": 10000,
        "optionDiscountAmount": 0,
        "finalAmount": 10000,
        "couponDiscountAmount": 0
      },
      "paymentMethod": {
        "type": "CARD",
        "cardName": "테스트카드",
        "maskedCardNumber": "1234-****-****-5678"
      },
      "questionAnswers": [],
      "payment": {
        "purchasedAt": "2026-04-20T14:22:11+09:00"
      },
      "trackingLink": {
        "code": "x5w7s62u",
        "name": "인스타그램 광고"
      },
      "sellerReference": "usr_sample_42"
    }
  }
}
```

---

## 3\. `payment.cancel_requested` (일반결제 취소 요청)

일반결제 취소 요청 이벤트입니다. 요청 단계이므로 최종 처리 완료 이벤트로 보면 안 됩니다.

### `payment.completed` 대비 차이

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `merchantUid` | `string` | 원 결제와 동일한 값 |
| `content.type` | `string` | `DOCUMENT` · `SERVICE` · `GOODS` · `EVENT` 중 하나 |
| `sellerReference` | `string` | **포함되지 않음**. `sellerReference`는 sellerReference 절의 포함 범위에 해당하는 이벤트에만 실립니다. 취소 건 매칭은 원 결제와 동일한 `merchantUid`로 해 주세요 |
| `pricing.coupon` | `object` | 포함되지 않음 |
| `payment.purchasedAt` | `string` | 원 구매 시각 |
| `paymentMethod.cardName` | `string` | PG 조회 실패 시 생략 가능 |
| `paymentMethod.maskedCardNumber` | `string` | PG 조회 실패 시 생략 가능 |

### cancelRequest

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `cancelRequest.reason` | `object` | 사유 블록 |
| `cancelRequest.reason.code` | `string` | `OTHER_PAYMENT_METHOD`(다른 수단으로 결제할게요) · `CHANGED_MIND`(마음이 바뀌었어요) · `FOUND_CHEAPER_CONTENT`(더 저렴한 상품을 찾았어요) · `ETC`(기타)<br><br>2026-08-25 이후 발행분은 구매자가 사유를 고르는 단계가 없어져 항상 `ETC`(기타) 입니다. 실제 사유는 `cancelRequest.detailReason` 에서 읽어 주세요. 위 4종은 그 이전 발행분에 남아 있는 값입니다.<br><br>새로운 값이 예고 없이 추가될 수 있습니다. 모르는 값을 받으면 오류로 처리하지 말고 `reason.label` 을 그대로 표시하거나 "기타" 로 처리해 주세요. |
| `cancelRequest.reason.label` | `string` | 사유 한글 라벨 |
| `cancelRequest.detailReason` | `string` | 구매자가 쓴 상세 사유. 2026-08-20 이후 발행분은 항상 포함(필수 입력, 10~500자). 그 이전 발행분은 없으면 생략 |
| `cancelRequest.requestedBy` | `string` | 현재 `BUYER`(구매자) 고정 |
| `cancelRequest.requestedAt` | `string` | ISO-8601 KST 형식의 취소 요청 시각 |

### 참고

-   `shipping`과 `pricing.shippingFee`는 `content.type == GOODS`일 때만 포함됩니다.
    
-   즉시 접근 차단이나 최종 정산 반영 기준으로 쓰면 안 됩니다.
    

### JSON 예시

#### 펼쳐서 보기 👀

```json
{
  "id": "evt_test_c1d2e3f4a5b60718293c4d5e",
  "type": "payment.cancel_requested",
  "version": "2026-04-30",
  "occurredAt": "2026-04-20T14:22:11+09:00",
  "data": {
    "object": {
      "merchantUid": "test_merchant_0001",
      "buyer": {
        "type": "MEMBER",
        "displayName": "테스트 구매자",
        "email": "test@buyer.example",
        "phoneNumber": "01012345678"
      },
      "content": {
        "id": "test_content_0001",
        "title": "테스트 실물 상품",
        "type": "GOODS",
        "paymentType": "ONE_TIME",
        "inputMode": "NORMAL"
      },
      "options": [
        {
          "optionId": "test_option_0001",
          "name": "기본 옵션",
          "description": "테스트용 기본 옵션",
          "quantity": 1,
          "unitOriginalPrice": 10000,
          "unitDiscountedPrice": 10000,
          "additionalOptions": [],
          "subtotal": 10000
        }
      ],
      "pricing": {
        "currency": "KRW",
        "originalAmount": 10000,
        "optionDiscountAmount": 0,
        "shippingFee": 3000,
        "finalAmount": 13000,
        "couponDiscountAmount": 0
      },
      "paymentMethod": {
        "type": "CARD",
        "cardName": "테스트카드",
        "maskedCardNumber": "1234-****-****-5678"
      },
      "shipping": {
        "address": "서울시 강남구 테헤란로 123",
        "streetAddress": "서울시 강남구 테헤란로 123",
        "detailAddress": "101동 202호",
        "deliveryRequest": "문 앞에 놓아주세요"
      },
      "questionAnswers": [],
      "cancelRequest": {
        "reason": {
          "code": "ETC",
          "label": "기타"
        },
        "detailReason": "색상이 마음에 들지 않아요",
        "requestedBy": "BUYER",
        "requestedAt": "2026-04-20T14:22:11+09:00"
      },
      "payment": {
        "purchasedAt": "2026-04-19T14:22:11+09:00"
      }
    }
  }
}
```

---

## 4\. `payment.refunded` (일반결제 취소 완료)

일반결제 취소 완료 이벤트입니다. 취소가 실제로 처리된 시점에 발행되므로, 접근 차단이나 판매자 시스템 반영은 이 이벤트를 기준으로 하면 됩니다.

`payment.cancel_requested`는 구매자의 **요청** 시점이고, 이 이벤트는 **처리 완료** 시점입니다. 구매자 취소 요청을 판매자가 승인한 건이라면 두 이벤트가 순서대로 도착하지만, 판매자·운영팀이 직접 취소한 건이나 구매자가 즉시 취소한 건은 요청 이벤트 없이 이 이벤트만 도착합니다.

정기결제 회차의 취소는 이 이벤트가 아니라 `subscription_payment.refunded`로 전달됩니다. `payment.completed`와 `subscription_payment.completed`가 나뉘는 것과 같은 기준입니다.

### `payment.completed` 대비 차이

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `merchantUid` | `string` | 원 결제와 동일한 값 |
| `payment.purchasedAt` | `string` | 원 구매 시각 |
| `pricing` | `object` | **원 구매 금액 그대로**입니다. 이번에 환불된 금액은 `refund.amount`에서 확인해 주세요 |
| `paymentMethod` | `object` | 포함되지 않음 |
| `questionAnswers[]` | `array` | 포함되지 않음 |
| `trackingLink` | `object` | 포함되지 않음 |
| `sellerReference` | `string` | **포함되지 않음**. `sellerReference`는 sellerReference 절의 포함 범위에 해당하는 이벤트에만 실립니다. 취소 건 매칭은 원 결제와 동일한 `merchantUid`로 해 주세요 |
| `refund` | `object` | 추가됨 (아래 참고) |

> `paymentMethod` · `questionAnswers[]` · `sellerReference`는 의도적으로 생략합니다. 취소 이벤트는 "얼마가, 왜, 누구에 의해 취소되었는가"만 전달하고, 원 결제의 부가 정보가 필요하면 완료 이벤트를 받은 시점에 저장해 둔 값을 `merchantUid`로 조회해 쓰면 됩니다.

### refund

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `refund.amount` | `number` | **이번 환불 금액**. `partialRefund == true`면 전액이 아니라 이번 부분 취소분입니다 |
| `refund.currency` | `string` | 항상 `KRW` |
| `refund.partialRefund` | `boolean` | 부분 환불 여부 |
| `refund.cancelledBy` | `string` | `BUYER`(구매자) · `SELLER`(판매자) · `ADMIN`(그로블 운영팀) |
| `refund.reason` | `string` | 환불 사유. 사유가 남아 있지 않으면 생략 |
| `refund.refundedAt` | `string` | ISO-8601 KST 형식의 환불 완료 시각 |

### 부분 환불 누적 계산

`refund.partialRefund == true`이면 아래를 전제로 처리해 주세요.

-   **부분 취소 1건마다 이벤트가 1개씩 발행됩니다.** 같은 결제(`merchantUid`)에 대해 이 이벤트가 여러 번 도착할 수 있습니다.
    
-   `refund.amount`는 매번 **이번 건의 금액**이고, 형제 블록인 `pricing`은 계속 **원 구매 금액**을 가리킵니다. `pricing`은 환불이 일어나도 줄어들지 않습니다.
    
-   따라서 **누적 환불액이 필요하면 수신 측에서 이벤트를 합산**해야 합니다. 합산 시 중복 집계를 막으려면 `merchantUid` + `refund.refundedAt` 조합을 멱등 키로 써 주세요.
    

### `refund.cancelledBy` 읽는 법

| 값 | 의미 |
| --- | --- |
| `BUYER` | 구매자가 발신한 취소입니다. **구매자의 직접 취소와 "구매자 취소 요청 → 판매자 승인" 두 경우가 모두 포함됩니다** (둘 다 취소를 발신한 쪽은 구매자이기 때문입니다). 승인 건인지 구분해야 하면, 같은 `merchantUid`로 선행 `payment.cancel_requested`를 받은 적이 있는지로 판단해 주세요 |
| `SELLER` | 판매자가 직접 취소한 건 |
| `ADMIN` | 그로블 운영팀이 취소한 건 |

### JSON 예시

#### 펼쳐서 보기 👀

```json
{
  "id": "evt_test_e1f2a3b4c5d60718293e4f5a",
  "type": "payment.refunded",
  "version": "2026-04-30",
  "occurredAt": "2026-04-20T14:22:11+09:00",
  "data": {
    "object": {
      "merchantUid": "test_merchant_0001",
      "buyer": {
        "type": "MEMBER",
        "displayName": "테스트 구매자",
        "email": "test@buyer.example",
        "phoneNumber": "01012345678"
      },
      "content": {
        "id": "test_content_0001",
        "title": "테스트 상품",
        "type": "DOCUMENT",
        "paymentType": "ONE_TIME",
        "inputMode": "NORMAL"
      },
      "options": [
        {
          "optionId": "test_option_0001",
          "name": "기본 옵션",
          "description": "테스트용 기본 옵션",
          "quantity": 1,
          "unitOriginalPrice": 10000,
          "unitDiscountedPrice": 10000,
          "additionalOptions": [],
          "subtotal": 10000
        }
      ],
      "pricing": {
        "currency": "KRW",
        "originalAmount": 10000,
        "optionDiscountAmount": 0,
        "finalAmount": 10000,
        "couponDiscountAmount": 0
      },
      "payment": {
        "purchasedAt": "2026-04-18T14:22:11+09:00"
      },
      "refund": {
        "amount": 10000,
        "currency": "KRW",
        "partialRefund": false,
        "cancelledBy": "BUYER",
        "reason": "마음이 바뀌었어요",
        "refundedAt": "2026-04-20T14:22:11+09:00"
      }
    }
  }
}
```

---

## 5\. `subscription_payment.completed` (정기결제 완료)

정기결제 완료 이벤트입니다. `payment.completed`와 거의 같은 구조에 `subscription` 블록이 추가됩니다.

> `trackingLink` 블록(유입 추적링크)은 `payment.completed`와 동일하게 포함됩니다. 최초 결제(`INITIAL`)뿐 아니라 갱신(`RENEWAL`) 회차에도 **가입 시점의 유입 추적링크**가 동일하게 실립니다.
> 
> `sellerReference`도 `payment.completed`와 동일하게 포함됩니다. 주문 생성 시점에 저장된 값이 최초 결제(`INITIAL`)뿐 아니라 이후 모든 갱신(`RENEWAL`) 회차 웹훅에도 동일하게 반환됩니다. 제약·권장 사항은 `sellerReference` 절을 참고해 주세요.

### `payment.completed` 대비 차이

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `buyer.type` | `string` | 회원이 결제하면 `MEMBER`(회원), 비회원이 결제하면 `GUEST`(비회원) |
| `content.paymentType` | `string` | 항상 `SUBSCRIPTION`(정기결제) |
| `options[]` | `array` | 항상 길이 1 |
| `options[].quantity` | `number` | 항상 1 |
| `options[].additionalOptions` | `array` | 항상 `[]` |
| `paymentMethod` | `object` | `billingReason == INITIAL`일 때만 포함 |
| `questionAnswers[]` | `array` | `billingReason == INITIAL`일 때만 포함 |

### subscription

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `subscription.billingReason` | `string` | `INITIAL`(최초 결제) 또는 `RENEWAL`(정기 갱신) |
| `subscription.currentRound` | `number` | 현재 결제 회차 |
| `subscription.nextBillingDate` | `string` | `YYYY-MM-DD` 형식의 다음 결제 예정일 |
| `subscription.status` | `string` | 항상 `ACTIVE`(활성) |
| `subscription.activatedAt` | `string` | ISO-8601 KST 형식의 최초 가입 시각 |
| `subscription.lastBillingSucceededAt` | `string` | ISO-8601 KST 형식의 직전 결제 성공 시각 |
| `subscription.billingCycleMonths` | `number` | 청구 주기(개월, 1~12). 정기결제 시작 시 동결된 값 |

### JSON 예시

#### 펼쳐서 보기 👀

```json
{
  "id": "evt_test_b1c2d3e4f5a60718293b4c5d",
  "type": "subscription_payment.completed",
  "version": "2026-04-30",
  "occurredAt": "2026-04-20T14:22:11+09:00",
  "data": {
    "object": {
      "merchantUid": "test_merchant_0001",
      "buyer": {
        "type": "MEMBER",
        "displayName": "테스트 구매자",
        "email": "test@buyer.example",
        "phoneNumber": "01012345678"
      },
      "content": {
        "id": "test_content_0001",
        "title": "테스트 정기결제 상품",
        "type": "DOCUMENT",
        "paymentType": "SUBSCRIPTION",
        "inputMode": "NORMAL"
      },
      "options": [
        {
          "optionId": "test_option_0001",
          "name": "월간 플랜",
          "description": "테스트용 월간 정기결제 옵션",
          "quantity": 1,
          "unitOriginalPrice": 9900,
          "unitDiscountedPrice": 9900,
          "additionalOptions": [],
          "subtotal": 9900
        }
      ],
      "pricing": {
        "currency": "KRW",
        "originalAmount": 9900,
        "optionDiscountAmount": 0,
        "finalAmount": 9900,
        "couponDiscountAmount": 0
      },
      "paymentMethod": {
        "type": "CARD",
        "cardName": "테스트카드",
        "maskedCardNumber": "1234-****-****-5678"
      },
      "questionAnswers": [],
      "payment": {
        "purchasedAt": "2026-04-20T14:22:11+09:00"
      },
      "trackingLink": {
        "code": "x5w7s62u",
        "name": "인스타그램 광고"
      },
      "sellerReference": "usr_sample_42",
      "subscription": {
        "billingReason": "INITIAL",
        "currentRound": 1,
        "nextBillingDate": "2026-05-20",
        "status": "ACTIVE",
        "activatedAt": "2026-04-20T14:22:11+09:00",
        "lastBillingSucceededAt": "2026-04-20T14:22:11+09:00",
        "billingCycleMonths": 1
      }
    }
  }
}
```

---

## 6\. `subscription_payment.refunded` (정기결제 취소 완료)

정기결제 취소 완료 이벤트입니다. **정기결제 전체가 아니라 회차 결제 1건**에 대한 취소입니다.

`payment.refunded`와 같은 구조에 `subscription` 블록이 추가됩니다. 취소된 결제가 정기결제 회차이면 이 이벤트로, 일반결제이면 `payment.refunded`로 발행됩니다.

> **회차 취소는 해지가 아닙니다.** 이 이벤트를 받아도 정기결제는 대개 그대로 유지됩니다.`subscription.status`는 보통 `ACTIVE`(활성)이고 `nextBillingDate`도 바뀌지 않아 다음 회차는 예정대로 청구됩니다. 정기결제를 끊는 것은 별도 이벤트인 `subscription.terminated`로 전달되므로, 이 이벤트를 접근 차단 신호로 쓰지 말아 주세요.

### `payment.refunded` 대비 차이

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `content.paymentType` | `string` | 항상 `SUBSCRIPTION`(정기결제) |
| `options[]` | `array` | 항상 길이 1 |
| `options[].quantity` | `number` | 항상 1 |
| `options[].additionalOptions` | `array` | 항상 `[]` |
| `subscription` | `object` | 추가됨 (아래 참고) |

### subscription

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `subscription.refundedRound` | `number` | **환불된 회차**. 이번 환불이 몇 회차 결제분인지 |
| `subscription.currentRound` | `number` | 정기결제의 현재 결제 회차. 과거 회차를 소급 환불하면 `refundedRound`보다 클 수 있습니다 |
| `subscription.status` | `string` | 환불 시점의 정기결제 상태. 회차 환불만으로는 해지되지 않으므로 보통 `ACTIVE`(활성) |
| `subscription.nextBillingDate` | `string` | `YYYY-MM-DD` 형식의 다음 결제 예정일. 회차 환불로 변경되지 않습니다. 없으면 생략 |
| `subscription.activatedAt` | `string` | ISO-8601 KST 형식의 최초 가입 시각 |
| `subscription.billingCycleMonths` | `number` | 청구 주기(개월, 1~12). 정기결제 시작 시 동결된 값 |

### 참고

-   부분 환불 누적 계산과 `refund.cancelledBy` 해석은 일반결제 취소 완료와 동일합니다.
    
-   `refund.partialRefund == true`인 회차 부분 환불에서도 `pricing`은 원 구매 금액을 그대로 유지합니다.
    

### JSON 예시

#### 펼쳐서 보기 👀

```json
{
  "id": "evt_test_f1a2b3c4d5e60718293f4a5b",
  "type": "subscription_payment.refunded",
  "version": "2026-04-30",
  "occurredAt": "2026-04-20T14:22:11+09:00",
  "data": {
    "object": {
      "merchantUid": "test_merchant_0001",
      "buyer": {
        "type": "MEMBER",
        "displayName": "테스트 구매자",
        "email": "test@buyer.example",
        "phoneNumber": "01012345678"
      },
      "content": {
        "id": "test_content_0001",
        "title": "테스트 정기결제 상품",
        "type": "DOCUMENT",
        "paymentType": "SUBSCRIPTION",
        "inputMode": "NORMAL"
      },
      "options": [
        {
          "optionId": "test_option_0001",
          "name": "월간 플랜",
          "description": "테스트용 월간 정기결제 옵션",
          "quantity": 1,
          "unitOriginalPrice": 9900,
          "unitDiscountedPrice": 9900,
          "additionalOptions": [],
          "subtotal": 9900
        }
      ],
      "pricing": {
        "currency": "KRW",
        "originalAmount": 9900,
        "optionDiscountAmount": 0,
        "finalAmount": 9900,
        "couponDiscountAmount": 0
      },
      "payment": {
        "purchasedAt": "2026-04-18T14:22:11+09:00"
      },
      "refund": {
        "amount": 9900,
        "currency": "KRW",
        "partialRefund": false,
        "cancelledBy": "SELLER",
        "reason": "판매자 직권 취소",
        "refundedAt": "2026-04-20T14:22:11+09:00"
      },
      "subscription": {
        "refundedRound": 3,
        "currentRound": 3,
        "status": "ACTIVE",
        "nextBillingDate": "2026-05-18",
        "activatedAt": "2026-01-20T14:22:11+09:00",
        "billingCycleMonths": 1
      }
    }
  }
}
```

---

## 7\. `subscription_payment.failed` (정기결제 갱신 실패)

정기결제 갱신 결제가 실패한 이벤트입니다. **매 실패 시도마다 1건씩 발행**되므로, 같은 정기결제에 대해 여러 번 도착하는 것이 정상입니다. `failure.attemptNumber`로 몇 번째 시도인지, `failure.isFinal`로 마지막 시도였는지 구분해 주세요.

### 실패 → 재시도 → 종료 흐름

1.  갱신 결제가 실패하면 이 이벤트가 `failure.isFinal == false`로 발행되고, `failure.nextRetryAt`에 다음 재시도 예정 시각이 실립니다. 이때 `subscription.status`는 `PAST_DUE`(연체)입니다.
    
2.  재시도가 최대 횟수에 도달하면 마지막 이벤트가 `failure.isFinal == true`로 발행되고, `failure.gracePeriodEndsAt`에 유예기간 종료 예정 시각이 실립니다.
    
3.  유예기간이 끝나면 `subscription.terminated`가 `termination.reason == PAYMENT_FAILURE_GRACE_EXPIRED`로 발행됩니다. 정기결제 해지 완료는 이 이벤트를 기준으로 처리해 주세요.
    

기본값은 **최대 3회 재시도 · 1일 간격 · 유예기간 7일**입니다.

### `subscription_payment.completed` 대비 차이

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `merchantUid` | `string` | **이번 실패 시도의 결제 식별자**입니다. 갱신 회차는 시도할 때마다 결제 건이 새로 발급되므로, 재시도 3회는 서로 다른 값 3개로 도착합니다. 결제가 이뤄지지 않았으므로 이 값으로는 완료 이벤트를 받은 적이 없습니다. **정기결제 매칭에 쓰지 마세요** (아래 `sellerReference` 참고) |
| `pricing.finalAmount` | `number` | 청구를 시도한 금액 (결제되지 않았습니다) |
| `pricing.shippingFee` | `number` | 포함되지 않습니다 |
| `paymentMethod` | `object` | 포함되지 않음 |
| `questionAnswers[]` | `array` | 포함되지 않음 |
| `payment` | `object` | 포함되지 않음 |
| `trackingLink` | `object` | 포함되지 않음 |
| `sellerReference` | `string` | 최초 결제 때 판매자가 넘긴 참조값이 그대로 실립니다. 갱신 회차마다 동일한 값이므로 **이 이벤트를 판매자 시스템의 정기결제에 연결하는 키로 써 주세요**. 최초 결제 때 값을 넘기지 않았다면 키가 생략됩니다 |
| `subscription.billingReason` | `string` | 항상 `RENEWAL`(정기 갱신) |
| `subscription.status` | `string` | `PAST_DUE`(연체, 재시도 중) 또는 `CANCELLED`(최종 실패) |
| `subscription.lastBillingSucceededAt` | `string` | ISO-8601 KST 형식의 직전 결제 성공 시각. 없으면 생략 |
| `failure` | `object` | 추가됨 (아래 참고) |

### failure

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `failure.attemptNumber` | `number` | 몇 번째 실패 시도인지 |
| `failure.isFinal` | `boolean` | 마지막 시도 여부. `true`면 더 이상 재시도하지 않고 유예기간으로 넘어갑니다 |
| `failure.reason` | `string` | 실패 사유 (예: `CARD_EXPIRED`) |
| `failure.failedAt` | `string` | ISO-8601 KST 형식의 실패 시각 |
| `failure.nextRetryAt` | `string` | ISO-8601 KST 형식의 다음 재시도 예정 시각. `isFinal == false`일 때만 포함 |
| `failure.gracePeriodEndsAt` | `string` | ISO-8601 KST 형식의 유예기간 종료 예정 시각. `isFinal == true`일 때만 포함 |

### 참고

-   같은 실패 시도가 재전송되어도 중복 처리하지 않으려면 `merchantUid` + `failure.attemptNumber`를 멱등 키로 써 주세요.
    
-   **어느 정기결제인지 찾을 때는** `sellerReference`**를 써 주세요.** `merchantUid`는 성공·실패를 막론하고 회차마다 새로 발급되므로 정기결제를 가리키는 값이 아닙니다.
    
-   이 이벤트는 결제 실패 사실만 알립니다. 정기결제를 종료하는 판단은 `subscription.terminated`로 해 주세요.
    

### JSON 예시

#### 펼쳐서 보기 👀

```json
{
  "id": "evt_test_a2b3c4d5e6f70718293a5b6c",
  "type": "subscription_payment.failed",
  "version": "2026-04-30",
  "occurredAt": "2026-04-20T14:22:11+09:00",
  "data": {
    "object": {
      "merchantUid": "test_merchant_0001",
      "sellerReference": "usr_sample_42",
      "buyer": {
        "type": "MEMBER",
        "displayName": "테스트 구매자",
        "email": "test@buyer.example",
        "phoneNumber": "01012345678"
      },
      "content": {
        "id": "test_content_0001",
        "title": "테스트 정기결제 상품",
        "type": "DOCUMENT",
        "paymentType": "SUBSCRIPTION",
        "inputMode": "NORMAL"
      },
      "options": [
        {
          "optionId": "test_option_0001",
          "name": "월간 플랜",
          "description": "테스트용 월간 정기결제 옵션",
          "quantity": 1,
          "unitOriginalPrice": 9900,
          "unitDiscountedPrice": 9900,
          "additionalOptions": [],
          "subtotal": 9900
        }
      ],
      "pricing": {
        "currency": "KRW",
        "originalAmount": 9900,
        "optionDiscountAmount": 0,
        "finalAmount": 9900,
        "couponDiscountAmount": 0
      },
      "subscription": {
        "billingReason": "RENEWAL",
        "currentRound": 3,
        "nextBillingDate": "2026-04-23",
        "status": "PAST_DUE",
        "activatedAt": "2026-02-20T14:22:11+09:00",
        "lastBillingSucceededAt": "2026-03-20T14:22:11+09:00",
        "billingCycleMonths": 1
      },
      "failure": {
        "attemptNumber": 1,
        "isFinal": false,
        "reason": "CARD_EXPIRED",
        "failedAt": "2026-04-20T14:22:11+09:00",
        "nextRetryAt": "2026-04-21T14:22:11+09:00"
      }
    }
  }
}
```

---

## 8\. `subscription.cancel_requested` (정기결제 해지 요청)

정기결제 해지 요청 이벤트입니다. 해지가 접수된 시점의 정기결제 상태 스냅샷을 전달합니다. 대부분은 요청 즉시 정기결제가 끊기지 않고, 이미 결제된 이용기간이 끝나면 `subscription.terminated`가 따로 발행됩니다.

> **2026-09-11 변경 / 이미 연동 중이라면 꼭 확인해 주세요.**  
> 지금까지 이 이벤트는 **구매자가 해지한 경우에만** 발행됐습니다. 이제 **판매자가 해지하는 경우에도 같은 이벤트가 발행**됩니다. 판매자 해지에는 사유 선택지가 없어서 `cancelRequest.reason`이 빈 객체(`{}`)로 오고, `cancelRequest.requestedBy`는 `SELLER`가 됩니다. 바로 아래 "판매자 해지가 추가되었습니다"에서 무엇을 확인하고 무엇을 고쳐야 하는지 정리했습니다.

### 판매자 해지가 추가되었습니다

2026-09-11부터 판매자도 정기결제를 끊을 수 있습니다. 경로는 두 가지이고 둘 다 이 이벤트를 발행합니다. 어느 경로인지는 새로 추가된 `cancellationSource`로 구분합니다.

| 경로 | `cancellationSource` | 언제 발행되나 |
| --- | --- | --- |
| 판매자 개별 해지 | `SELLER_INDIVIDUAL` | 판매자가 판매 상세 화면에서 이 정기결제 하나를 골라 해지한 경우 |
| 상품 판매 종료 | `CONTENT_TERMINATION` | 판매자가 정기결제 상품의 판매를 종료해 그 상품의 정기결제가 일괄 해지된 경우. **이전에는 이 경로에서** `**subscription.terminated**`**만 발행됐지만, 이제 해지 요청이 먼저 발행됩니다** |

구매자 해지와 달라지는 점은 세 가지뿐입니다.

| 항목 | 구매자 해지 (기존) | 판매자 해지 (신규) |
| --- | --- | --- |
| `cancelRequest.requestedBy` | `BUYER` | `SELLER` |
| `cancelRequest.reason` | 사유 선택지 `code` · `label` · `legacyCode`가 들어 있는 객체 | **빈 객체** `**{}**`. 판매자에게는 사유 선택지를 받지 않습니다 |
| `cancelRequest.detailReason` | 구매자가 쓴 상세 사유 | 판매자가 쓴 해지 사유(필수 입력, 10~500자). 구매자에게도 그대로 안내되는 공개 문구입니다 |

그 밖에 `cancellationSource`와 `serviceEndsAt` 두 필드가 판매자 해지에만 추가로 실립니다. 나머지 필드는 구매자 해지와 완전히 같습니다.

### 판매자 해지를 어떻게 처리하면 되나

결론부터 말하면 **대부분의 수신 코드는 고칠 필요가 없습니다.** 이 이벤트는 원래 "예고"이고 실제 차단은 `subscription.terminated`로 처리하도록 안내해 왔기 때문에, 그대로 따르고 있었다면 판매자 해지도 같은 방식으로 흘러갑니다. 다음 세 가지만 확인해 주세요.

-   `**cancelRequest.reason.code**`**를 반드시 있는 값으로 읽고 있다면 고쳐 주세요.** 판매자 해지에서는 이 값이 없습니다. 값이 없을 때 오류를 내지 말고 "판매자 해지"로 처리하거나 `cancelRequest.detailReason`을 그대로 보여주면 됩니다.
    
-   **해지 사유를 구매자에게 보여주는 화면이 있다면** `**requestedBy**`**를 함께 확인해 주세요.** 판매자 해지 사유를 "구매자가 남긴 사유"로 표시하면 오해를 부릅니다.
    
-   **해지 요청을 받고 곧바로 접근을 차단하는 코드가 있다면 이번 기회에 고쳐 주세요.** 판매자 해지에서도 이용기간은 대부분 남아 있습니다. 차단은 `subscription.terminated`를 기준으로 해 주세요.
    

이 이벤트 다음에 `subscription.terminated`가 언제 도착하는지는 정기결제의 상태에 따라 다릅니다.

| 해지 시점의 정기결제 상태 | `subscription.terminated` 도착 시점 | `termination.reason` |
| --- | --- | --- |
| 정상 이용 중 | 남은 이용기간이 끝난 뒤(기존 다음 결제 예정일 경과 후) | `PERIOD_END` |
| 결제 실패 상태이고 결제 예정일이 아직 지나지 않음 | 남은 이용기간이 끝난 뒤 | `PERIOD_END` |
| 결제 실패 상태이고 결제 예정일이 이미 지남 | **해지 요청과 거의 동시에** | `SELLER_CANCELLED` |
| 결제 실패 후 유예기간 중 | **해지 요청과 거의 동시에** | `SELLER_CANCELLED` |
| 상품 판매 종료로 일괄 해지 | **해지 요청과 거의 동시에** | `CONTENT_DELETED` |

> 즉시 종료되는 경우 `subscription.cancel_requested`와 `subscription.terminated`가 거의 같은 시각에 발행됩니다. **두 이벤트의 도착 순서는 보장되지 않습니다.** 해지 완료를 먼저 받아도 정상이므로, 해지 요청을 먼저 받아야만 완료를 처리하도록 만들지 말아 주세요.

### `subscription_payment.completed` 대비 차이

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `merchantUid` | `string` | 해지 요청 시점까지 **가장 최근에 결제된 회차**의 결제 식별자입니다. 갱신 회차는 결제마다 식별자가 새로 발급되므로 최초(1회차) 결제와 다를 수 있습니다. 정기결제 매칭은 `sellerReference`로 해 주세요 |
| `paymentMethod` | `object` | 포함되지 않음 |
| `questionAnswers[]` | `array` | 포함됩니다. 최초 결제 시 입력한 질문·응답 스냅샷. 없으면 `[]` |
| `shipping` | `object` | 포함되지 않음 |
| `pricing.shippingFee` | `number` | 포함되지 않음 |
| `sellerReference` | `string` | 최초 결제 때 판매자가 넘긴 참조값이 그대로 실립니다. 갱신 회차마다 동일한 값이므로 **이 이벤트를 판매자 시스템의 정기결제에 연결하는 키로 써 주세요**. 최초 결제 때 값을 넘기지 않았다면 키가 생략됩니다 |
| `payment.purchasedAt` | `string` | 원 구매 시각 |
| `cancellationSource` | `string` | **2026-09-11 추가.** 판매자 해지에만 포함됩니다. `SELLER_INDIVIDUAL`(판매자가 이 정기결제 하나를 해지) · `CONTENT_TERMINATION`(상품 판매 종료로 일괄 해지). 구매자가 해지한 건에는 키가 없습니다 |
| `serviceEndsAt` | `string` | **2026-09-11 추가.** 판매자 해지에만 포함됩니다. ISO-8601 KST 형식으로, 구매자가 이용할 수 있는 마지막 시각입니다. 즉시 종료되는 해지에서는 해지 처리 시각이 담깁니다 |

### subscription

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `subscription.status` | `string` | 항상 `CANCELLED`(해지 요청됨). |
| `subscription.currentRound` | `number` | 해지 시점의 누적 결제 회차 |
| `subscription.nextBillingDate` | `string` | `YYYY-MM-DD` 형식. 해지 요청 직전에 예정돼 있던 다음 결제 예정일이 그대로 담깁니다. 이미 결제된 이용기간이 이 날짜까지 이어지므로, 정기결제 해지 완료일의 참고값으로 쓸 수 있습니다<br><br>**판매자 해지 중 즉시 종료되는 건에서는 이 키가 생략되거나 이미 지난 날짜일 수 있습니다.** 이용 종료 시각은 `serviceEndsAt`을, 실제 종료는 `subscription.terminated`의 `termination.terminatedAt`을 기준으로 봐 주세요 |
| `subscription.activatedAt` | `string` | ISO-8601 KST 형식의 최초 가입 시각 |
| `subscription.cancelledAt` | `string` | ISO-8601 KST 형식의 해지 요청 시각 |
| `subscription.price` | `number` | 직전 결제 금액 |
| `subscription.currency` | `string` | 항상 `KRW` |
| `subscription.lastBillingFailureReason` | `string` | 직전 실패 사유. 없으면 생략 |
| `subscription.billingCycleMonths` | `number` | 청구 주기(개월, 1~12). 정기결제 시작 시 동결된 값 |

### cancelRequest

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `cancelRequest.reason` | `object` | 사유 블록. **판매자 해지에서는 빈 객체(**`**{}**`**)로 옵니다**. 판매자에게는 사유 선택지를 받지 않기 때문입니다. 키 자체는 항상 있으므로 `reason`의 존재 여부가 아니라 `reason.code`의 존재 여부로 판단해 주세요 |
| `cancelRequest.reason.code` | `string` | `NOT_USED`(서비스를 잘 이용하지 않아요) · `MISSING_FEATURES`(필요한 기능이 없어요) · `TOO_EXPENSIVE`(가격 및 비용이 부담돼요) · `OTHER`(기타)<br><br>2026-08-25 이전 발행분은 예전 4종(`OTHER_PAYMENT_METHOD` · `CHANGED_MIND` · `FOUND_CHEAPER_CONTENT` · `ETC`)으로 기록되어 있습니다.<br><br>**판매자가 해지한 건에는 이 값이 없습니다**(2026-09-11부터). 값이 없으면 오류로 처리하지 말고 `cancelRequest.requestedBy`를 확인해 "판매자 해지"로 처리해 주세요.<br><br>새로운 값이 예고 없이 추가될 수 있습니다. 모르는 값을 받으면 오류로 처리하지 말고 `reason.label` 을 그대로 표시하거나 "기타" 로 처리해 주세요. |
| `cancelRequest.reason.label` | `string` | 사유 한글 라벨 |
| `cancelRequest.reason.legacyCode` | `string` | 전환 유예용. 신규 `code` 와 같은 의미의 예전 코드(현재 항상 `ETC`). 예전 코드로 매핑하던 수신 코드는 당분간 이 값을 쓰면 수정 없이 동작합니다. 전환 기간이 끝나면 제거됩니다. |
| `cancelRequest.detailReason` | `string` | 해지한 사람이 직접 쓴 사유. 2026-08-20 이후 발행분은 항상 포함(필수 입력, 10~500자). 그 이전 발행분은 값이 있으면 포함<br><br>**누가 쓴 사유인지는** `**cancelRequest.requestedBy**`**로 판단해 주세요.** 판매자 해지에서는 판매자가 쓴 문구가 담기며, 이 문구는 구매자에게도 그대로 안내됩니다 |
| `cancelRequest.requestedBy` | `string` | `BUYER`(구매자) · `SELLER`(판매자)<br><br>**2026-09-11 이전에는 항상** `**BUYER**`**였습니다.** 판매자 해지가 추가되면서 `SELLER`가 올 수 있습니다. 앞으로 값이 더 늘어날 수 있으니 모르는 값은 오류로 처리하지 말아 주세요 |
| `cancelRequest.requestedAt` | `string` | ISO-8601 KST 형식의 해지 요청 시각 |

### 참고

-   `options[]`는 이 이벤트에서도 배열 형태로 전달됩니다.
    
-   현재 자발적 해지 payload에서는 `gracePeriodEndsAt`이 포함되지 않습니다.
    
-   `gracePeriodEndsAt`은 결제 재시도 실패 후 유예기간 흐름에서 사용하는 값입니다.
    
-   판매자 해지는 **환불이 아닙니다.** 이미 결제된 회차는 그대로 유지되며, 회차 환불이 필요하면 별도로 처리되어 `subscription_payment.refunded`가 따로 발행됩니다.
    
-   판매자가 해지한 정기결제는 구매자가 다시 시작할 수 없습니다. 같은 구매자가 같은 상품을 다시 결제하면 **완전히 새로운 정기결제**로 생성되어 `subscription_payment.completed`가 새로 발행됩니다.
    
-   같은 정기결제가 두 번 해지될 수는 없습니다. 중복 처리가 걱정된다면 `sellerReference` + `cancelRequest.requestedAt`을 멱등 키로 써 주세요.
    

### JSON 예시

#### 펼쳐서 보기 👀

```json
{
  "id": "evt_test_d1e2f3a4b5c60718293d4e5f",
  "type": "subscription.cancel_requested",
  "version": "2026-04-30",
  "occurredAt": "2026-04-20T14:22:11+09:00",
  "data": {
    "object": {
      "merchantUid": "test_merchant_0001",
      "sellerReference": "usr_sample_42",
      "buyer": {
        "type": "MEMBER",
        "displayName": "테스트 구매자",
        "email": "test@buyer.example",
        "phoneNumber": "01012345678"
      },
      "content": {
        "id": "test_content_0001",
        "title": "테스트 정기결제 상품",
        "type": "DOCUMENT",
        "paymentType": "SUBSCRIPTION",
        "inputMode": "NORMAL"
      },
      "options": [
        {
          "optionId": "test_option_0001",
          "name": "월간 플랜",
          "description": "테스트용 월간 정기결제 옵션",
          "quantity": 1,
          "unitOriginalPrice": 9900,
          "unitDiscountedPrice": 9900,
          "additionalOptions": [],
          "subtotal": 9900
        }
      ],
      "pricing": {
        "currency": "KRW",
        "originalAmount": 9900,
        "optionDiscountAmount": 0,
        "finalAmount": 9900,
        "couponDiscountAmount": 0
      },
      "questionAnswers": [],
      "payment": {
        "purchasedAt": "2026-01-20T14:22:11+09:00"
      },
      "subscription": {
        "status": "CANCELLED",
        "currentRound": 3,
        "nextBillingDate": "2026-05-05",
        "activatedAt": "2026-01-20T14:22:11+09:00",
        "cancelledAt": "2026-04-20T14:22:11+09:00",
        "price": 9900,
        "currency": "KRW",
        "billingCycleMonths": 1
      },
      "cancelRequest": {
        "reason": {
          "code": "NOT_USED",
          "label": "서비스를 잘 이용하지 않아요",
          "legacyCode": "ETC"
        },
        "detailReason": "지금은 더 이상 사용하지 않아요",
        "requestedBy": "BUYER",
        "requestedAt": "2026-04-20T14:22:11+09:00"
      }
    }
  }
}
```

판매자가 해지한 경우의 예시입니다. `cancelRequest.reason`이 비어 있고, `cancellationSource`와 `serviceEndsAt`이 추가로 실린 것만 다릅니다.

#### 판매자 해지 예시 펼쳐서 보기 👀

```json
{
  "id": "evt_test_c3d4e5f6a7b80718293c6d7e",
  "type": "subscription.cancel_requested",
  "version": "2026-04-30",
  "occurredAt": "2026-04-20T14:22:11+09:00",
  "data": {
    "object": {
      "merchantUid": "test_merchant_0001",
      "sellerReference": "usr_sample_42",
      "buyer": {
        "type": "MEMBER",
        "displayName": "테스트 구매자",
        "email": "test@buyer.example",
        "phoneNumber": "01012345678"
      },
      "content": {
        "id": "test_content_0001",
        "title": "테스트 정기결제 상품",
        "type": "DOCUMENT",
        "paymentType": "SUBSCRIPTION",
        "inputMode": "NORMAL"
      },
      "options": [
        {
          "optionId": "test_option_0001",
          "name": "월간 플랜",
          "description": "테스트용 월간 정기결제 옵션",
          "quantity": 1,
          "unitOriginalPrice": 9900,
          "unitDiscountedPrice": 9900,
          "additionalOptions": [],
          "subtotal": 9900
        }
      ],
      "pricing": {
        "currency": "KRW",
        "originalAmount": 9900,
        "optionDiscountAmount": 0,
        "finalAmount": 9900,
        "couponDiscountAmount": 0
      },
      "questionAnswers": [],
      "payment": {
        "purchasedAt": "2026-01-20T14:22:11+09:00"
      },
      "subscription": {
        "status": "CANCELLED",
        "currentRound": 3,
        "nextBillingDate": "2026-05-05",
        "activatedAt": "2026-01-20T14:22:11+09:00",
        "cancelledAt": "2026-04-20T14:22:11+09:00",
        "price": 9900,
        "currency": "KRW",
        "billingCycleMonths": 1
      },
      "cancelRequest": {
        "reason": {},
        "detailReason": "동일 상품을 새 플랜으로 옮기면서 기존 정기결제를 정리합니다",
        "requestedBy": "SELLER",
        "requestedAt": "2026-04-20T14:22:11+09:00"
      },
      "cancellationSource": "SELLER_INDIVIDUAL",
      "serviceEndsAt": "2026-05-05T00:00:00+09:00"
    }
  }
}
```

---

## 9\. `subscription.terminated` (정기결제 해지 완료)

정기결제 해지 완료 이벤트입니다. **정기결제가 실제로 끝나는 시점**에 발행되므로, 상품 접근 차단은 이 이벤트를 기준으로 처리하면 됩니다.

`subscription.cancel_requested`와 시점이 다릅니다. 해지 **요청**은 "다음 회차부터 갱신하지 않겠다"는 예고이고, 그 시점에는 이미 결제된 이용기간이 남아 있어 정기결제가 유지됩니다. 이 이벤트는 그 이용기간까지 끝난 시점에 도착합니다.

정기결제가 끝나는 경로는 여러 개지만 이벤트는 이것 하나입니다. 어떤 경로로 끝났는지는 `termination.reason`으로 구분해 주세요. "해지 완료"를 알기 위해 여러 이벤트를 구독하고 OR로 묶지 않아도 됩니다.

> **2026-09-11 변경 / 이미 연동 중이라면 꼭 확인해 주세요.**  
> 판매자 해지가 추가되면서 `termination.reason`에 새 값 `SELLER_CANCELLED`가 생겼습니다. 그리고 **누가 왜 해지했는지**를 알려주는 `cancelledBy` · `cancellationSource` · `cancelDetailReason` 세 필드가 **모든 해지 완료 이벤트**에 추가됐습니다. 지금까지는 `PERIOD_END`만으로 구매자 해지와 운영팀 해지를 구분할 수 없어 선행 이벤트 수신 여부로 추측해야 했는데, 이제 `cancelledBy` 하나로 바로 구분됩니다.

### `subscription.cancel_requested` 대비 차이

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `subscription.nextBillingDate` | `string` | **포함되지 않음** |
| `subscription.status` | `string` | 항상 `CANCELLED`(해지됨) |
| `subscription.cancelledAt` | `string` | ISO-8601 KST 형식의 해지 처리 시각. 정기결제가 완전히 종료된 시각은 `termination.terminatedAt`입니다 |
| `subscription.lastBillingFailureReason` | `string` | 직전 갱신 결제 실패 사유. `termination.reason == PAYMENT_FAILURE_GRACE_EXPIRED`일 때 채워집니다. 없으면 생략 |
| `questionAnswers[]` | `array` | 포함되지 않음 |
| `payment` | `object` | 포함되지 않음 |
| `cancelRequest` | `object` | 포함되지 않음. 구매자가 해지한 건의 사유는 선행 `subscription.cancel_requested`에서 확인해 주세요. 판매자가 해지한 건의 사유는 아래 `cancelDetailReason`에 그대로 실립니다 |
| `cancelledBy` | `string` | **2026-09-11 추가.** 해지를 실행한 주체입니다. `BUYER`(구매자) · `SELLER`(판매자) · `ADMIN`(그로블 운영팀) · `SYSTEM`(결제 실패 등 자동 처리). 값이 없는 과거 건에서는 키가 생략됩니다 |
| `cancellationSource` | `string` | **2026-09-11 추가.** 해지 경로입니다. `SELLER_INDIVIDUAL`(판매자 개별 해지) · `CONTENT_TERMINATION`(상품 판매 종료) · `ADMIN_OVERRIDE`(운영팀 상태 변경). 구매자 해지와 결제 실패에는 키가 없습니다 |
| `cancelDetailReason` | `string` | **2026-09-11 추가.** `cancelledBy`가 `SELLER`일 때만 포함되는, 판매자가 쓴 해지 사유입니다. 구매자에게도 그대로 안내되는 공개 문구입니다 |
| `termination` | `object` | 추가됨 (아래 참고) |

### termination

| 경로 | 타입 | 의미 |
| --- | --- | --- |
| `termination.reason` | `string` | 해지 경로. `PERIOD_END` · `PAYMENT_FAILURE_GRACE_EXPIRED` · `SELLER_CANCELLED` · `CONTENT_DELETED` (아래 표 참고). 앞으로 경로가 추가될 수 있으므로, 모르는 값이 오면 무시하고 `terminatedAt`만으로 해지 완료를 처리해 주세요 |
| `termination.terminatedAt` | `string` | ISO-8601 KST 형식의 **정기결제가 실제로 끝난 시각**. 기간 만료형은 이용기간 만료 시점, 즉시 종료형은 처리 시각 |
| `termination.gracePeriodExpiredAt` | `string` | ISO-8601 KST 형식의 유예기간 만료 시각. `termination.reason == PAYMENT_FAILURE_GRACE_EXPIRED`일 때만 포함 |

### `termination.reason` 읽는 법

| 값 | 의미 |
| --- | --- |
| `PERIOD_END` | 해지 이후 결제된 이용기간이 만료되었습니다. **가장 흔한 경로**입니다. 구매자 자진 해지, 운영팀 해지, 그리고 **2026-09-11부터는 판매자 해지**까지 모두 포함합니다. 세 경로 모두 즉시 정기결제를 끊지 않고 이미 결제된 기간까지 이용을 보장한 뒤 종료되기 때문입니다.<br><br>누가 해지했는지는 `**cancelledBy**`**로 구분해 주세요** (`BUYER` · `SELLER` · `ADMIN`). 예전에는 이 필드가 없어 선행 `subscription.cancel_requested` 수신 여부로 추측하도록 안내했지만, **판매자 해지도 그 이벤트를 발행하므로 그 방법은 더 이상 구매자 해지를 가려내지 못합니다.** `cancelledBy`가 없는 과거 건에만 예전 방법을 쓰세요 |
| `PAYMENT_FAILURE_GRACE_EXPIRED` | 갱신 결제가 반복 실패해 유예기간까지 소진되었습니다. 이 경로에서만 `termination.gracePeriodExpiredAt`이 함께 실리고, `subscription.lastBillingFailureReason`도 채워집니다. 선행 흐름은 정기결제 갱신 실패를 참고해 주세요 |
| `SELLER_CANCELLED` | **2026-09-11 신설.** 판매자가 이 정기결제 하나를 해지했고, 남은 이용기간이 없어 **즉시 종료**된 경우입니다. 결제 실패 후 유예기간 중이거나 결제 예정일이 이미 지난 정기결제를 판매자가 해지하면 이 값으로 옵니다. 판매자가 해지했더라도 이용기간이 남아 있으면 즉시 종료되지 않고 `PERIOD_END`로 나중에 도착합니다. `cancelDetailReason`에 판매자가 쓴 사유가 함께 실립니다 |
| `CONTENT_DELETED` | 판매자가 **정기결제 상품의 판매를 종료**해 해당 상품의 정기결제가 일괄 종료되었습니다. 잔여 기간과 무관하게 즉시 종료됩니다. `cancelledBy`는 `SELLER`, `cancellationSource`는 `CONTENT_TERMINATION`입니다. **2026-09-11부터는 이 경로에서도** `**subscription.cancel_requested**`**가 먼저 발행됩니다** (이전에는 이 이벤트만 단독으로 발행됐습니다) |

### 참고

-   중복 처리를 막으려면 `merchantUid` + `termination.terminatedAt`을 멱등 키로 써 주세요. 해지 후 재가입·재해지가 가능하므로 정기결제 식별자만으로는 구분되지 않습니다.
    
-   즉시 종료되는 해지(`SELLER_CANCELLED` · `CONTENT_DELETED`)는 `subscription.cancel_requested`와 거의 같은 시각에 발행됩니다. **두 이벤트의 도착 순서는 보장되지 않으므로**, 해지 요청을 먼저 받아야만 완료를 처리하도록 만들지 말아 주세요.
    

### JSON 예시

가장 흔한 경로인 `PERIOD_END` 기준 예시입니다. 결제 실패로 인한 종료는 `termination.reason`이 `PAYMENT_FAILURE_GRACE_EXPIRED`로 바뀌고 `termination.gracePeriodExpiredAt`이 추가됩니다.

#### 펼쳐서 보기 👀

```json
{
  "id": "evt_test_b2c3d4e5f6a70718293b5c6d",
  "type": "subscription.terminated",
  "version": "2026-04-30",
  "occurredAt": "2026-04-20T14:22:11+09:00",
  "data": {
    "object": {
      "merchantUid": "test_merchant_0001",
      "sellerReference": "usr_sample_42",
      "buyer": {
        "type": "MEMBER",
        "displayName": "테스트 구매자",
        "email": "test@buyer.example",
        "phoneNumber": "01012345678"
      },
      "content": {
        "id": "test_content_0001",
        "title": "테스트 정기결제 상품",
        "type": "DOCUMENT",
        "paymentType": "SUBSCRIPTION",
        "inputMode": "NORMAL"
      },
      "options": [
        {
          "optionId": "test_option_0001",
          "name": "월간 플랜",
          "description": "테스트용 월간 정기결제 옵션",
          "quantity": 1,
          "unitOriginalPrice": 9900,
          "unitDiscountedPrice": 9900,
          "additionalOptions": [],
          "subtotal": 9900
        }
      ],
      "pricing": {
        "currency": "KRW",
        "originalAmount": 9900,
        "optionDiscountAmount": 0,
        "finalAmount": 9900,
        "couponDiscountAmount": 0
      },
      "subscription": {
        "status": "CANCELLED",
        "currentRound": 3,
        "activatedAt": "2026-01-20T14:22:11+09:00",
        "cancelledAt": "2026-04-05T14:22:11+09:00",
        "price": 9900,
        "currency": "KRW",
        "billingCycleMonths": 1
      },
      "termination": {
        "reason": "PERIOD_END",
        "terminatedAt": "2026-04-20T14:22:11+09:00"
      }
    }
  }
}
```

---

## 업데이트 이력

| 날짜 | 업데이트 내용 |
| --- | --- |
| `2026-09-11` | **신규: 판매자도 정기결제를 해지할 수 있습니다.** 판매자 개별 해지와 상품 판매 종료 모두 `subscription.cancel_requested`를 발행합니다. 지금까지 이 이벤트는 구매자 해지에서만 발행됐습니다.<br><br>변경: `cancelRequest.requestedBy`에 `SELLER` 추가(이전에는 항상 `BUYER`). 판매자 해지에는 사유 선택지가 없어 `cancelRequest.reason`이 빈 객체로 오고, `cancelRequest.detailReason`에 판매자가 쓴 사유가 담깁니다.<br><br>신규: `subscription.cancel_requested`에 `cancellationSource`(`SELLER_INDIVIDUAL` · `CONTENT_TERMINATION`)와 `serviceEndsAt` 추가. 판매자 해지에만 실립니다.<br><br>신규: `subscription.terminated`에 `cancelledBy` · `cancellationSource` · `cancelDetailReason` 추가. `cancelledBy`로 구매자·판매자·운영팀 해지를 바로 구분할 수 있습니다.<br><br>신규: `termination.reason`에 `SELLER_CANCELLED` 추가. 판매자가 해지했고 남은 이용기간이 없어 즉시 종료된 경우입니다.<br><br>변경: 상품 판매 종료(`CONTENT_DELETED`) 경로에서도 이제 `subscription.cancel_requested`가 먼저 발행됩니다. 이전에는 `subscription.terminated`만 단독 발행됐습니다. |
| `2026-08-25` | 변경: 일반결제 취소 요청(`payment.cancel_requested`)에서 구매자가 사유를 고르는 단계를 없애고 직접 쓴 문장만 받도록 변경. `cancelRequest.reason.code` 는 이후 발행분부터 항상 `ETC`(기타)로 나가며, 실제 사유는 `cancelRequest.detailReason` 에 담깁니다. 정기결제 해지(`subscription.cancel_requested`)의 사유 선택지는 그대로입니다. |
| `2026-08-20` | 정기결제 해지(`subscription.cancel_requested`)의 사유 선택지를 멤버십 해지와 같은 4종(`NOT_USED` / `MISSING_FEATURES` / `TOO_EXPENSIVE` / `OTHER`)으로 변경. 예전 코드로 매핑하던 수신 코드를 위해 전환 유예 필드 `reason.legacyCode` 를 함께 전송(전환 기간 후 제거 예정). 구매자 취소 요청과 정기결제 해지의 상세 사유가 필수 입력(10~500자)이 되어 `cancelRequest.detailReason` 이 항상 포함됩니다. |
| `2026-08-11` | 신규: 결제성 이벤트 5종(`payment.completed` / `subscription_payment.completed` / `payment.cancel_requested` / `payment.refunded` / `subscription_payment.refunded)` 응답에 배송비(`shippingFee`) 필드를 추가 |
| `2026-07-26` | 변경: `sellerReference`(판매자 참조값)를 `subscription_payment.failed`응답에 추가하여 결제가 시도된 모든 이벤트에 대해 참조값을 활용할 수 있도록 확장<br>변경: `subscription_payment.failed`의 `merchantUid`가 직전 성공 회차가 아니라 해당 실패 시도의 결제 식별자로 정정<br>변경: `sellerReference`(판매자 참조값)를 `subscription.cancel_requested` · `subscription.terminated`에 추가. 정기결제 단위 매칭 키를 `sellerReference`로 일원화<br>변경: 정기결제 해지 계열 이벤트의 `merchantUid`가 원 결제가 아니라 **가장 최근에 결제된 회차**의 식별자임을 명시 |
| `2026-07-24` | 공개 이벤트 4종 → **8종**으로 확대.<br>신규: `payment.refunded`(일반결제 취소 완료) · `subscription_payment.refunded`(정기결제 취소 완료, 회차 단위) · `subscription_payment.failed`(정기결제 갱신 실패) · `subscription.terminated`(정기결제 해지 완료), `payment.completed` · `subscription_payment.completed`에 `sellerReference`(판매자 참조값)를 추가 |
| `2026-07-05` | 변경: `questionAnswers[]`를 `subscription.cancel_requested`(정기결제 해지 요청)에 신규 추가<br>기존: `questionAnswers[]`를 `subscription.cancel_requested`(정기결제 해지 요청)에서 제외 |
| `2026-07-02` | 변경: `buyer.phoneNumber`(구매자 전화번호)를 모든 웹훅 응답 필드에 추가<br>기존: `contentType==GOODS`에만 한정 |
| `2026-07-01` | 모든 이벤트 `content` 블록에 상품 입력 모드 `content.inputMode`(`NORMAL` · `SIMPLE` · `PAYMENT_WINDOW`) 추가. 정기결제 이벤트(`subscription_payment.completed` · `subscription.cancel_requested`) `subscription` 블록에 청구 주기 `subscription.billingCycleMonths`(1~12개월) 추가 |
| `2026-06-27` | `payment.completed` · `subscription_payment.completed` 이벤트에 `trackingLink`(유입 추적링크 `code` · `name`) 필드 추가 |
