웹훅 이벤트
목차
1. 이벤트 한눈에 보기
이벤트 타입 | 의미 |
|---|---|
| 일반결제 완료 |
| 일반결제 취소 요청 |
| 일반결제 취소 완료 |
| 정기결제 완료 |
| 정기결제 취소 완료 (회차 단위) |
| 정기결제 갱신 실패 |
| 정기결제 해지 요청 |
| 정기결제 해지 완료 |
이름 규칙
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 대비 차이만 적습니다.
필드 명세
최상위
경로 | 타입 | 의미 |
|---|---|---|
|
| 그로블이 발급한 결제 식별자 |
|
| 판매자가 전달한 참조값. 전달하지 않았거나 서버에서 폐기된 경우 키 자체가 생략됩니다. 상세 제약은 아래 |
buyer
경로 | 타입 | 의미 |
|---|---|---|
|
|
|
|
| 구매자 표시 이름. 회원은 닉네임, 비회원은 입력 이름 기준. 상품 유형과 관계없이 항상 포함 |
|
| 구매자 이메일. 상품 유형과 관계없이 항상 포함 |
|
| 구매자 전화번호. 상품 유형과 관계없이 항상 포함 |
content
경로 | 타입 | 의미 |
|---|---|---|
|
| 인코딩된 상품 ID |
|
| 구매 시점 상품명 |
|
|
|
|
| 항상 |
|
| 상품 입력 모드 |
options[]
경로 | 타입 | 의미 |
|---|---|---|
|
| 인코딩된 옵션 ID |
|
| 옵션명 스냅샷 |
|
| 옵션 설명. 없으면 생략 |
|
| 구매 수량 |
|
| 할인 전 단가 |
|
| 할인 후 단가 |
|
| 옵션 할인 블록. 없으면 생략 |
|
|
|
|
| 할인 값 |
|
| 타임딜 할인일 때만 포함 |
|
| 타임딜 라벨 |
|
| 추가 옵션 배열. 없으면 |
|
| 인코딩된 추가 옵션 ID |
|
| 추가 옵션명 |
|
| 추가 옵션 수량 |
|
| 추가 옵션 단가 |
|
| 옵션 소계 |
pricing
경로 | 타입 | 의미 |
|---|---|---|
|
| 항상 |
|
| 할인 전 총액 |
|
| 옵션/타임딜 할인 합계. 쿠폰 할인 제외 |
|
| 배송비. 실물 상품( |
|
| 실제 결제 금액 |
|
| 쿠폰 할인 금액 |
|
| 쿠폰 블록. 없으면 생략 |
|
| 쿠폰 코드 |
|
| 쿠폰 이름 |
|
| 쿠폰 할인 유형 |
|
| 쿠폰 할인 금액 |
paymentMethod
경로 | 타입 | 의미 |
|---|---|---|
|
|
|
|
| 카드사명 |
|
| 마스킹된 카드번호 |
shipping
content.type == GOODS일 때만 포함됩니다.
경로 | 타입 | 의미 |
|---|---|---|
|
| 주소 |
|
| 도로명/지번 주소 |
|
| 상세 주소 |
|
| 배송 요청 사항 |
questionAnswers[]
경로 | 타입 | 의미 |
|---|---|---|
|
| 질문 본문 |
|
| 질문 유형 |
|
| 필수 여부 |
|
| 구매자 응답 |
|
| 표시 순서 |
payment
경로 | 타입 | 의미 |
|---|---|---|
|
| ISO-8601 KST 형식의 구매 시각 |
trackingLink
구매자가 유입 추적링크로 진입한 결제만 포함됩니다. 추적링크 없이 직접 진입한 결제면 trackingLink 블록 자체가 생략됩니다.
경로 | 타입 | 의미 |
|---|---|---|
|
| 유입 추적링크 코드 (예: |
|
| 유입 추적링크 이름 (판매자 설정값) |
sellerReference
판매자가 결제창 진입 링크에 ?ref=<value>를 붙여 값을 지정한 경우에만 포함됩니다. 전달하지 않았거나 아래 제약을 위반해 서버에서 값이 폐기된 경우 키 자체가 생략됩니다.
경로 | 타입 | 의미 |
|---|---|---|
|
| 판매자 시스템의 주문/사용자 식별자 등 불투명 참조값 |
포함되는 이벤트 범위
sellerReference는 결제가 시도된 이벤트와 정기결제 상태 변화 이벤트에 포함됩니다:payment.completed,subscription_payment.completed(최초 결제INITIAL뿐 아니라 이후 모든 갱신RENEWAL회차에도 동일하게 포함),subscription_payment.failed,subscription.cancel_requested,subscription.terminatedpayment.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 대비 차이
경로 | 타입 | 의미 |
|---|---|---|
|
| 원 결제와 동일한 값 |
|
|
|
|
| 포함되지 않음. |
|
| 포함되지 않음 |
|
| 원 구매 시각 |
|
| PG 조회 실패 시 생략 가능 |
|
| PG 조회 실패 시 생략 가능 |
cancelRequest
경로 | 타입 | 의미 |
|---|---|---|
|
| 사유 블록 |
|
|
2026-08-25 이후 발행분은 구매자가 사유를 고르는 단계가 없어져 항상 새로운 값이 예고 없이 추가될 수 있습니다. 모르는 값을 받으면 오류로 처리하지 말고 |
|
| 사유 한글 라벨 |
|
| 구매자가 쓴 상세 사유. 2026-08-20 이후 발행분은 항상 포함(필수 입력, 10~500자). 그 이전 발행분은 없으면 생략 |
|
| 현재 |
|
| ISO-8601 KST 형식의 취소 요청 시각 |
참고
shipping과pricing.shippingFee는content.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.completed와 subscription_payment.completed가 나뉘는 것과 같은 기준입니다.
payment.completed 대비 차이
경로 | 타입 | 의미 |
|---|---|---|
|
| 원 결제와 동일한 값 |
|
| 원 구매 시각 |
|
| 원 구매 금액 그대로입니다. 이번에 환불된 금액은 |
|
| 포함되지 않음 |
|
| 포함되지 않음 |
|
| 포함되지 않음 |
|
| 포함되지 않음. |
|
| 추가됨 (아래 참고) |
paymentMethod·questionAnswers[]·sellerReference는 의도적으로 생략합니다. 취소 이벤트는 "얼마가, 왜, 누구에 의해 취소되었는가"만 전달하고, 원 결제의 부가 정보가 필요하면 완료 이벤트를 받은 시점에 저장해 둔 값을merchantUid로 조회해 쓰면 됩니다.
refund
경로 | 타입 | 의미 |
|---|---|---|
|
| 이번 환불 금액. |
|
| 항상 |
|
| 부분 환불 여부 |
|
|
|
|
| 환불 사유. 사유가 남아 있지 않으면 생략 |
|
| ISO-8601 KST 형식의 환불 완료 시각 |
부분 환불 누적 계산
refund.partialRefund == true이면 아래를 전제로 처리해 주세요.
부분 취소 1건마다 이벤트가 1개씩 발행됩니다. 같은 결제(
merchantUid)에 대해 이 이벤트가 여러 번 도착할 수 있습니다.refund.amount는 매번 이번 건의 금액이고, 형제 블록인pricing은 계속 원 구매 금액을 가리킵니다.pricing은 환불이 일어나도 줄어들지 않습니다.따라서 누적 환불액이 필요하면 수신 측에서 이벤트를 합산해야 합니다. 합산 시 중복 집계를 막으려면
merchantUid+refund.refundedAt조합을 멱등 키로 써 주세요.
refund.cancelledBy 읽는 법
값 | 의미 |
|---|---|
| 구매자가 발신한 취소입니다. 구매자의 직접 취소와 "구매자 취소 요청 → 판매자 승인" 두 경우가 모두 포함됩니다 (둘 다 취소를 발신한 쪽은 구매자이기 때문입니다). 승인 건인지 구분해야 하면, 같은 |
| 판매자가 직접 취소한 건 |
| 그로블 운영팀이 취소한 건 |
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 대비 차이
경로 | 타입 | 의미 |
|---|---|---|
|
| 회원이 결제하면 |
|
| 항상 |
|
| 항상 길이 1 |
|
| 항상 1 |
|
| 항상 |
|
|
|
|
|
|
subscription
경로 | 타입 | 의미 |
|---|---|---|
|
|
|
|
| 현재 결제 회차 |
|
|
|
|
| 항상 |
|
| ISO-8601 KST 형식의 최초 가입 시각 |
|
| ISO-8601 KST 형식의 직전 결제 성공 시각 |
|
| 청구 주기(개월, 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 대비 차이
경로 | 타입 | 의미 |
|---|---|---|
|
| 항상 |
|
| 항상 길이 1 |
|
| 항상 1 |
|
| 항상 |
|
| 추가됨 (아래 참고) |
subscription
경로 | 타입 | 의미 |
|---|---|---|
|
| 환불된 회차. 이번 환불이 몇 회차 결제분인지 |
|
| 정기결제의 현재 결제 회차. 과거 회차를 소급 환불하면 |
|
| 환불 시점의 정기결제 상태. 회차 환불만으로는 해지되지 않으므로 보통 |
|
|
|
|
| ISO-8601 KST 형식의 최초 가입 시각 |
|
| 청구 주기(개월, 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로 마지막 시도였는지 구분해 주세요.
실패 → 재시도 → 종료 흐름
갱신 결제가 실패하면 이 이벤트가
failure.isFinal == false로 발행되고,failure.nextRetryAt에 다음 재시도 예정 시각이 실립니다. 이때subscription.status는PAST_DUE(연체)입니다.재시도가 최대 횟수에 도달하면 마지막 이벤트가
failure.isFinal == true로 발행되고,failure.gracePeriodEndsAt에 유예기간 종료 예정 시각이 실립니다.유예기간이 끝나면
subscription.terminated가termination.reason == PAYMENT_FAILURE_GRACE_EXPIRED로 발행됩니다. 정기결제 해지 완료는 이 이벤트를 기준으로 처리해 주세요.
기본값은 최대 3회 재시도 · 1일 간격 · 유예기간 7일입니다.
subscription_payment.completed 대비 차이
경로 | 타입 | 의미 |
|---|---|---|
|
| 이번 실패 시도의 결제 식별자입니다. 갱신 회차는 시도할 때마다 결제 건이 새로 발급되므로, 재시도 3회는 서로 다른 값 3개로 도착합니다. 결제가 이뤄지지 않았으므로 이 값으로는 완료 이벤트를 받은 적이 없습니다. 정기결제 매칭에 쓰지 마세요 (아래 |
|
| 청구를 시도한 금액 (결제되지 않았습니다) |
|
| 포함되지 않습니다 |
|
| 포함되지 않음 |
|
| 포함되지 않음 |
|
| 포함되지 않음 |
|
| 포함되지 않음 |
|
| 최초 결제 때 판매자가 넘긴 참조값이 그대로 실립니다. 갱신 회차마다 동일한 값이므로 이 이벤트를 판매자 시스템의 정기결제에 연결하는 키로 써 주세요. 최초 결제 때 값을 넘기지 않았다면 키가 생략됩니다 |
|
| 항상 |
|
|
|
|
| ISO-8601 KST 형식의 직전 결제 성공 시각. 없으면 생략 |
|
| 추가됨 (아래 참고) |
failure
경로 | 타입 | 의미 |
|---|---|---|
|
| 몇 번째 실패 시도인지 |
|
| 마지막 시도 여부. |
|
| 실패 사유 (예: |
|
| ISO-8601 KST 형식의 실패 시각 |
|
| ISO-8601 KST 형식의 다음 재시도 예정 시각. |
|
| ISO-8601 KST 형식의 유예기간 종료 예정 시각. |
참고
같은 실패 시도가 재전송되어도 중복 처리하지 않으려면
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.requestedBy는SELLER가 됩니다. 바로 아래 "판매자 해지가 추가되었습니다"에서 무엇을 확인하고 무엇을 고쳐야 하는지 정리했습니다.
판매자 해지가 추가되었습니다
2026-09-11부터 판매자도 정기결제를 끊을 수 있습니다. 경로는 두 가지이고 둘 다 이 이벤트를 발행합니다. 어느 경로인지는 새로 추가된 cancellationSource로 구분합니다.
경로 |
| 언제 발행되나 |
|---|---|---|
판매자 개별 해지 |
| 판매자가 판매 상세 화면에서 이 정기결제 하나를 골라 해지한 경우 |
상품 판매 종료 |
| 판매자가 정기결제 상품의 판매를 종료해 그 상품의 정기결제가 일괄 해지된 경우. 이전에는 이 경로에서 |
구매자 해지와 달라지는 점은 세 가지뿐입니다.
항목 | 구매자 해지 (기존) | 판매자 해지 (신규) |
|---|---|---|
|
|
|
| 사유 선택지 | 빈 객체 |
| 구매자가 쓴 상세 사유 | 판매자가 쓴 해지 사유(필수 입력, 10~500자). 구매자에게도 그대로 안내되는 공개 문구입니다 |
그 밖에 cancellationSource와 serviceEndsAt 두 필드가 판매자 해지에만 추가로 실립니다. 나머지 필드는 구매자 해지와 완전히 같습니다.
판매자 해지를 어떻게 처리하면 되나
결론부터 말하면 대부분의 수신 코드는 고칠 필요가 없습니다. 이 이벤트는 원래 "예고"이고 실제 차단은 subscription.terminated로 처리하도록 안내해 왔기 때문에, 그대로 따르고 있었다면 판매자 해지도 같은 방식으로 흘러갑니다. 다음 세 가지만 확인해 주세요.
cancelRequest.reason.code를 반드시 있는 값으로 읽고 있다면 고쳐 주세요. 판매자 해지에서는 이 값이 없습니다. 값이 없을 때 오류를 내지 말고 "판매자 해지"로 처리하거나cancelRequest.detailReason을 그대로 보여주면 됩니다.해지 사유를 구매자에게 보여주는 화면이 있다면
requestedBy를 함께 확인해 주세요. 판매자 해지 사유를 "구매자가 남긴 사유"로 표시하면 오해를 부릅니다.해지 요청을 받고 곧바로 접근을 차단하는 코드가 있다면 이번 기회에 고쳐 주세요. 판매자 해지에서도 이용기간은 대부분 남아 있습니다. 차단은
subscription.terminated를 기준으로 해 주세요.
이 이벤트 다음에 subscription.terminated가 언제 도착하는지는 정기결제의 상태에 따라 다릅니다.
해지 시점의 정기결제 상태 |
|
|
|---|---|---|
정상 이용 중 | 남은 이용기간이 끝난 뒤(기존 다음 결제 예정일 경과 후) |
|
결제 실패 상태이고 결제 예정일이 아직 지나지 않음 | 남은 이용기간이 끝난 뒤 |
|
결제 실패 상태이고 결제 예정일이 이미 지남 | 해지 요청과 거의 동시에 |
|
결제 실패 후 유예기간 중 | 해지 요청과 거의 동시에 |
|
상품 판매 종료로 일괄 해지 | 해지 요청과 거의 동시에 |
|
즉시 종료되는 경우
subscription.cancel_requested와subscription.terminated가 거의 같은 시각에 발행됩니다. 두 이벤트의 도착 순서는 보장되지 않습니다. 해지 완료를 먼저 받아도 정상이므로, 해지 요청을 먼저 받아야만 완료를 처리하도록 만들지 말아 주세요.
subscription_payment.completed 대비 차이
경로 | 타입 | 의미 |
|---|---|---|
|
| 해지 요청 시점까지 가장 최근에 결제된 회차의 결제 식별자입니다. 갱신 회차는 결제마다 식별자가 새로 발급되므로 최초(1회차) 결제와 다를 수 있습니다. 정기결제 매칭은 |
|
| 포함되지 않음 |
|
| 포함됩니다. 최초 결제 시 입력한 질문·응답 스냅샷. 없으면 |
|
| 포함되지 않음 |
|
| 포함되지 않음 |
|
| 최초 결제 때 판매자가 넘긴 참조값이 그대로 실립니다. 갱신 회차마다 동일한 값이므로 이 이벤트를 판매자 시스템의 정기결제에 연결하는 키로 써 주세요. 최초 결제 때 값을 넘기지 않았다면 키가 생략됩니다 |
|
| 원 구매 시각 |
|
| 2026-09-11 추가. 판매자 해지에만 포함됩니다. |
|
| 2026-09-11 추가. 판매자 해지에만 포함됩니다. ISO-8601 KST 형식으로, 구매자가 이용할 수 있는 마지막 시각입니다. 즉시 종료되는 해지에서는 해지 처리 시각이 담깁니다 |
subscription
경로 | 타입 | 의미 |
|---|---|---|
|
| 항상 |
|
| 해지 시점의 누적 결제 회차 |
|
|
판매자 해지 중 즉시 종료되는 건에서는 이 키가 생략되거나 이미 지난 날짜일 수 있습니다. 이용 종료 시각은 |
|
| ISO-8601 KST 형식의 최초 가입 시각 |
|
| ISO-8601 KST 형식의 해지 요청 시각 |
|
| 직전 결제 금액 |
|
| 항상 |
|
| 직전 실패 사유. 없으면 생략 |
|
| 청구 주기(개월, 1~12). 정기결제 시작 시 동결된 값 |
cancelRequest
경로 | 타입 | 의미 |
|---|---|---|
|
| 사유 블록. 판매자 해지에서는 빈 객체( |
|
|
2026-08-25 이전 발행분은 예전 4종( 판매자가 해지한 건에는 이 값이 없습니다(2026-09-11부터). 값이 없으면 오류로 처리하지 말고 새로운 값이 예고 없이 추가될 수 있습니다. 모르는 값을 받으면 오류로 처리하지 말고 |
|
| 사유 한글 라벨 |
|
| 전환 유예용. 신규 |
|
| 해지한 사람이 직접 쓴 사유. 2026-08-20 이후 발행분은 항상 포함(필수 입력, 10~500자). 그 이전 발행분은 값이 있으면 포함 누가 쓴 사유인지는 |
|
|
2026-09-11 이전에는 항상 |
|
| 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이 비어 있고, cancellationSource와 serviceEndsAt이 추가로 실린 것만 다릅니다.
판매자 해지 예시 펼쳐서 보기 👀
{
"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 대비 차이
경로 | 타입 | 의미 |
|---|---|---|
|
| 포함되지 않음 |
|
| 항상 |
|
| ISO-8601 KST 형식의 해지 처리 시각. 정기결제가 완전히 종료된 시각은 |
|
| 직전 갱신 결제 실패 사유. |
|
| 포함되지 않음 |
|
| 포함되지 않음 |
|
| 포함되지 않음. 구매자가 해지한 건의 사유는 선행 |
|
| 2026-09-11 추가. 해지를 실행한 주체입니다. |
|
| 2026-09-11 추가. 해지 경로입니다. |
|
| 2026-09-11 추가. |
|
| 추가됨 (아래 참고) |
termination
경로 | 타입 | 의미 |
|---|---|---|
|
| 해지 경로. |
|
| ISO-8601 KST 형식의 정기결제가 실제로 끝난 시각. 기간 만료형은 이용기간 만료 시점, 즉시 종료형은 처리 시각 |
|
| ISO-8601 KST 형식의 유예기간 만료 시각. |
termination.reason 읽는 법
값 | 의미 |
|---|---|
| 해지 이후 결제된 이용기간이 만료되었습니다. 가장 흔한 경로입니다. 구매자 자진 해지, 운영팀 해지, 그리고 2026-09-11부터는 판매자 해지까지 모두 포함합니다. 세 경로 모두 즉시 정기결제를 끊지 않고 이미 결제된 기간까지 이용을 보장한 뒤 종료되기 때문입니다. 누가 해지했는지는 |
| 갱신 결제가 반복 실패해 유예기간까지 소진되었습니다. 이 경로에서만 |
| 2026-09-11 신설. 판매자가 이 정기결제 하나를 해지했고, 남은 이용기간이 없어 즉시 종료된 경우입니다. 결제 실패 후 유예기간 중이거나 결제 예정일이 이미 지난 정기결제를 판매자가 해지하면 이 값으로 옵니다. 판매자가 해지했더라도 이용기간이 남아 있으면 즉시 종료되지 않고 |
| 판매자가 정기결제 상품의 판매를 종료해 해당 상품의 정기결제가 일괄 종료되었습니다. 잔여 기간과 무관하게 즉시 종료됩니다. |
참고
중복 처리를 막으려면
merchantUid+termination.terminatedAt을 멱등 키로 써 주세요. 해지 후 재가입·재해지가 가능하므로 정기결제 식별자만으로는 구분되지 않습니다.즉시 종료되는 해지(
SELLER_CANCELLED·CONTENT_DELETED)는subscription.cancel_requested와 거의 같은 시각에 발행됩니다. 두 이벤트의 도착 순서는 보장되지 않으므로, 해지 요청을 먼저 받아야만 완료를 처리하도록 만들지 말아 주세요.
JSON 예시
가장 흔한 경로인 PERIOD_END 기준 예시입니다. 결제 실패로 인한 종료는 termination.reason이 PAYMENT_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"
}
}
}
}
업데이트 이력
날짜 | 업데이트 내용 |
|---|---|
| 신규: 판매자도 정기결제를 해지할 수 있습니다. 판매자 개별 해지와 상품 판매 종료 모두 변경: 신규: 신규: 신규: 변경: 상품 판매 종료( |
| 변경: 일반결제 취소 요청( |
| 정기결제 해지( |
| 신규: 결제성 이벤트 5종( |
| 변경: |
| 공개 이벤트 4종 → 8종으로 확대. |
| 변경: |
| 변경: |
| 모든 이벤트 |
|
|