가이드

웹훅 이벤트

목차

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

상품 입력 모드
NORMAL(판매 페이지) · SIMPLE(간편 모드) · PAYMENT_WINDOW(결제창)
간편 모드는 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(무료 결제). FREEcardName·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 예시

펼쳐서 보기 👀
{
  "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(기타)

2026-08-25 이후 발행분은 구매자가 사유를 고르는 단계가 없어져 항상 ETC(기타) 입니다. 실제 사유는 cancelRequest.detailReason 에서 읽어 주세요. 위 4종은 그 이전 발행분에 남아 있는 값입니다.

새로운 값이 예고 없이 추가될 수 있습니다. 모르는 값을 받으면 오류로 처리하지 말고 reason.label 을 그대로 표시하거나 "기타" 로 처리해 주세요.

cancelRequest.reason.label

string

사유 한글 라벨

cancelRequest.detailReason

string

구매자가 쓴 상세 사유. 2026-08-20 이후 발행분은 항상 포함(필수 입력, 10~500자). 그 이전 발행분은 없으면 생략

cancelRequest.requestedBy

string

현재 BUYER(구매자) 고정

cancelRequest.requestedAt

string

ISO-8601 KST 형식의 취소 요청 시각

참고

  • shippingpricing.shippingFeecontent.type == GOODS일 때만 포함됩니다.

  • 즉시 접근 차단이나 최종 정산 반영 기준으로 쓰면 안 됩니다.

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.completedsubscription_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 예시

펼쳐서 보기 👀
{
  "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) 회차에도 가입 시점의 유입 추적링크가 동일하게 실립니다.

sellerReferencepayment.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 예시

펼쳐서 보기 👀
{
  "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 예시

펼쳐서 보기 👀
{
  "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.statusPAST_DUE(연체)입니다.

  2. 재시도가 최대 횟수에 도달하면 마지막 이벤트가 failure.isFinal == true로 발행되고, failure.gracePeriodEndsAt에 유예기간 종료 예정 시각이 실립니다.

  3. 유예기간이 끝나면 subscription.terminatedtermination.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 예시

펼쳐서 보기 👀
{
  "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.requestedBySELLER가 됩니다. 바로 아래 "판매자 해지가 추가되었습니다"에서 무엇을 확인하고 무엇을 고쳐야 하는지 정리했습니다.

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

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

경로

cancellationSource

언제 발행되나

판매자 개별 해지

SELLER_INDIVIDUAL

판매자가 판매 상세 화면에서 이 정기결제 하나를 골라 해지한 경우

상품 판매 종료

CONTENT_TERMINATION

판매자가 정기결제 상품의 판매를 종료해 그 상품의 정기결제가 일괄 해지된 경우. 이전에는 이 경로에서 subscription.terminated만 발행됐지만, 이제 해지 요청이 먼저 발행됩니다

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

항목

구매자 해지 (기존)

판매자 해지 (신규)

cancelRequest.requestedBy

BUYER

SELLER

cancelRequest.reason

사유 선택지 code · label · legacyCode가 들어 있는 객체

빈 객체 {}. 판매자에게는 사유 선택지를 받지 않습니다

cancelRequest.detailReason

구매자가 쓴 상세 사유

판매자가 쓴 해지 사유(필수 입력, 10~500자). 구매자에게도 그대로 안내되는 공개 문구입니다

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

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

결론부터 말하면 대부분의 수신 코드는 고칠 필요가 없습니다. 이 이벤트는 원래 "예고"이고 실제 차단은 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_requestedsubscription.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 형식. 해지 요청 직전에 예정돼 있던 다음 결제 예정일이 그대로 담깁니다. 이미 결제된 이용기간이 이 날짜까지 이어지므로, 정기결제 해지 완료일의 참고값으로 쓸 수 있습니다

판매자 해지 중 즉시 종료되는 건에서는 이 키가 생략되거나 이미 지난 날짜일 수 있습니다. 이용 종료 시각은 serviceEndsAt을, 실제 종료는 subscription.terminatedtermination.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(기타)

2026-08-25 이전 발행분은 예전 4종(OTHER_PAYMENT_METHOD · CHANGED_MIND · FOUND_CHEAPER_CONTENT · ETC)으로 기록되어 있습니다.

판매자가 해지한 건에는 이 값이 없습니다(2026-09-11부터). 값이 없으면 오류로 처리하지 말고 cancelRequest.requestedBy를 확인해 "판매자 해지"로 처리해 주세요.

새로운 값이 예고 없이 추가될 수 있습니다. 모르는 값을 받으면 오류로 처리하지 말고 reason.label 을 그대로 표시하거나 "기타" 로 처리해 주세요.

cancelRequest.reason.label

string

사유 한글 라벨

cancelRequest.reason.legacyCode

string

전환 유예용. 신규 code 와 같은 의미의 예전 코드(현재 항상 ETC). 예전 코드로 매핑하던 수신 코드는 당분간 이 값을 쓰면 수정 없이 동작합니다. 전환 기간이 끝나면 제거됩니다.

cancelRequest.detailReason

string

해지한 사람이 직접 쓴 사유. 2026-08-20 이후 발행분은 항상 포함(필수 입력, 10~500자). 그 이전 발행분은 값이 있으면 포함

누가 쓴 사유인지는 cancelRequest.requestedBy로 판단해 주세요. 판매자 해지에서는 판매자가 쓴 문구가 담기며, 이 문구는 구매자에게도 그대로 안내됩니다

cancelRequest.requestedBy

string

BUYER(구매자) · SELLER(판매자)

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 예시

펼쳐서 보기 👀
{
  "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이 비어 있고, cancellationSourceserviceEndsAt이 추가로 실린 것만 다릅니다.

판매자 해지 예시 펼쳐서 보기 👀
{
  "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 추가. cancelledBySELLER일 때만 포함되는, 판매자가 쓴 해지 사유입니다. 구매자에게도 그대로 안내되는 공개 문구입니다

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부터는 판매자 해지까지 모두 포함합니다. 세 경로 모두 즉시 정기결제를 끊지 않고 이미 결제된 기간까지 이용을 보장한 뒤 종료되기 때문입니다.

누가 해지했는지는 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

판매자가 정기결제 상품의 판매를 종료해 해당 상품의 정기결제가 일괄 종료되었습니다. 잔여 기간과 무관하게 즉시 종료됩니다. cancelledBySELLER, cancellationSourceCONTENT_TERMINATION입니다. 2026-09-11부터는 이 경로에서도 subscription.cancel_requested가 먼저 발행됩니다 (이전에는 이 이벤트만 단독으로 발행됐습니다)

참고

  • 중복 처리를 막으려면 merchantUid + termination.terminatedAt을 멱등 키로 써 주세요. 해지 후 재가입·재해지가 가능하므로 정기결제 식별자만으로는 구분되지 않습니다.

  • 즉시 종료되는 해지(SELLER_CANCELLED · CONTENT_DELETED)는 subscription.cancel_requested와 거의 같은 시각에 발행됩니다. 두 이벤트의 도착 순서는 보장되지 않으므로, 해지 요청을 먼저 받아야만 완료를 처리하도록 만들지 말아 주세요.

JSON 예시

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

펼쳐서 보기 👀
{
  "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를 발행합니다. 지금까지 이 이벤트는 구매자 해지에서만 발행됐습니다.

변경: cancelRequest.requestedBySELLER 추가(이전에는 항상 BUYER). 판매자 해지에는 사유 선택지가 없어 cancelRequest.reason이 빈 객체로 오고, cancelRequest.detailReason에 판매자가 쓴 사유가 담깁니다.

신규: subscription.cancel_requestedcancellationSource(SELLER_INDIVIDUAL · CONTENT_TERMINATION)와 serviceEndsAt 추가. 판매자 해지에만 실립니다.

신규: subscription.terminatedcancelledBy · cancellationSource · cancelDetailReason 추가. cancelledBy로 구매자·판매자·운영팀 해지를 바로 구분할 수 있습니다.

신규: termination.reasonSELLER_CANCELLED 추가. 판매자가 해지했고 남은 이용기간이 없어 즉시 종료된 경우입니다.

변경: 상품 판매 종료(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응답에 추가하여 결제가 시도된 모든 이벤트에 대해 참조값을 활용할 수 있도록 확장
변경: subscription_payment.failedmerchantUid가 직전 성공 회차가 아니라 해당 실패 시도의 결제 식별자로 정정
변경: sellerReference(판매자 참조값)를 subscription.cancel_requested · subscription.terminated에 추가. 정기결제 단위 매칭 키를 sellerReference로 일원화
변경: 정기결제 해지 계열 이벤트의 merchantUid가 원 결제가 아니라 가장 최근에 결제된 회차의 식별자임을 명시

2026-07-24

공개 이벤트 4종 → 8종으로 확대.
신규: payment.refunded(일반결제 취소 완료) · subscription_payment.refunded(정기결제 취소 완료, 회차 단위) · subscription_payment.failed(정기결제 갱신 실패) · subscription.terminated(정기결제 해지 완료), payment.completed · subscription_payment.completedsellerReference(판매자 참조값)를 추가

2026-07-05

변경: questionAnswers[]subscription.cancel_requested(정기결제 해지 요청)에 신규 추가
기존: questionAnswers[]subscription.cancel_requested(정기결제 해지 요청)에서 제외

2026-07-02

변경: buyer.phoneNumber(구매자 전화번호)를 모든 웹훅 응답 필드에 추가
기존: 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) 필드 추가