# PayRi Docs — 전체 문서 > 페이리(PayRi) 공식 문서예요. 기능 소개와 셀러 가이드, REST API·웹훅·라이선스·SDK 개발자 문서를 담고 있어요. ## 계약 요약 - Base URL은 `https://api.payri.kr`이고 경로는 `/v1/...`예요. 인증은 `Authorization: Bearer pyr_live_…` 또는 `pyr_test_…`를 사용해요. API 키는 `pyr_live_`·`pyr_test_` 접두사 뒤 base62 38자(본문 32자·체크섬 6자)가 붙는 서버 비밀키이고, mode 없는 `key_…` ID는 콘솔·로그 식별용이에요. 키 회전 유예는 24시간이며 유예 키 호출에는 `Payri-Key-Deprecated: <만료 ISO 8601>` 응답 헤더가 오고, 웹훅 시크릿은 엔드포인트별 `whsec_…`예요. SDK 패키지는 `@payri/sdk` 1.0.0이에요. - 권한은 `products:read`, `checkout:read`, `checkout:write`, `orders:read`, `refunds:read`, `refunds:write`, `subscriptions:read`, `subscriptions:write`, `licenses:read` 9종이며 기본 선택은 없어요. - 라이선스 앱 API를 제외한 모든 v1 POST에는 `Idempotency-Key`가 필수예요. 키는 1~255자이고 누락하면 400 `VALIDATION_FAILED`의 `details.fields`에 필드 `Idempotency-Key`가 와요. 키는 store·mode 범위에서 메서드·경로·본문 해시와 함께 24시간 보관돼요. 재생 응답에는 `Idempotent-Replayed: true`가 오며, 본문 충돌과 처리 중 요청은 각각 409 `IDEMPOTENCY_KEY_CONFLICT`, `IDEMPOTENCY_IN_PROGRESS`예요. 검증 400과 5xx 응답은 저장하지 않아 같은 키와 요청으로 다시 보내면 다시 처리해요. - 오류 코드와 HTTP 상태의 원천은 OpenAPI의 `x-payri-error-catalog`예요. 오류 형식은 `{ statusCode, code, message, details }`예요. `details`는 항상 객체예요. `VALIDATION_FAILED`는 400이고 `details.fields`는 `{ field, message }` 객체 배열이에요. `API_KEY_INVALID`는 헤더 누락·키 형식·체크섬 오류·미등록·유예 만료를 포함한 401, `API_KEY_IP_NOT_ALLOWED`와 `API_KEY_SCOPE_MISSING`은 403이고 후자는 `details.required`(string[])에 필요한 권한이 와요. 404 코드는 `PRODUCT_NOT_FOUND`, `CHECKOUT_SESSION_NOT_FOUND`, `ORDER_NOT_FOUND`, `REFUND_REQUEST_NOT_FOUND`, `SUBSCRIPTION_NOT_FOUND`, `LICENSE_NOT_FOUND`예요. 모르는 필드도 400 `VALIDATION_FAILED`이며 API 키당 분당 300회 제한 초과는 429 `RATE_LIMITED`와 `Retry-After`를 반환해요. - 모든 객체 응답에는 `mode`(`live` 또는 `test`)가 있고 없는 값은 `null`로 내려와요. 금액은 정수와 `currency`로 표현하고 목록은 `{ data, total, page, limit }`예요. `page` 기본값은 1, `limit` 기본값은 20이고 최대 100이에요. `sort`·`order`를 보내면 400으로 거절돼요. - 웹훅 요청 본문은 `{ id, type, createdAt, mode, data }`이고 본문과 `data`의 `mode`는 항상 포함돼요. `webhook-id`는 이벤트 ID와 같고 재시도·콘솔 재전송에도 같으며, 콘솔 재전송은 새 `webhook-timestamp`로 서명을 다시 계산해요. `webhook-signature` 형식은 `v1,`이고 시크릿 회전 유예 24시간에는 `v1,<새 서명> v1,<이전 서명>`이 전송돼요. 타임스탬프 허용 오차는 5분이며 수신 엔드포인트는 10초 안에 응답해야 해요. 전달 순서는 보장되지 않으므로 ID로 중복을 제거하고 순서가 필요하면 `createdAt`으로 정렬해요. 2xx만 성공으로 처리하고 재시도는 즉시·1분·5분·30분·2시간·6시간·12시간·24시간 순서로 8회, 약 45시간 동안 이어져요. - 이벤트는 `order.paid`(최초·갱신·차액은 `data.subscriptionEvent`의 `initial | renewal | proration`으로 구분하고 비구독 주문은 `null`), `order.refunded`(주문과 `refund: { id, amount, currency, reason, refundedAt }`), `subscription.created`(`data.change.kind`: `created`), `subscription.updated`(`data.change.kind`: `plan_changed | plan_change_scheduled | billing_interval_changed | billing_interval_change_scheduled | cancel_scheduled | cancel_resumed | period_renewed | period_extended`, change 필드: `kind, from, to, prorationAmount, orderId, effectiveAt, extensionDays, amount, currency`), `subscription.ended`(`endReason`: `canceled | refunded | payment_failed | product_unavailable | plan_unavailable | pg_canceled`), `subscription.payment_failed`(`attempts, amount, currency`), `refund.requested`, `refund.approved`, `refund.rejected`, `refund.on_hold`, `refund.withdrawn`, `license.issue`, `webhook.test`예요. `webhook.test`는 `live`·`test` 엔드포인트 모두로 보낼 수 있어요. `license.issue`의 data는 `{ licenseId, orderId, productId, planId, interval, buyer: { email, name }, maxActivations, expiresAt }`이고 응답은 `{ key, expiresAt?, maxActivations? }`예요. `key`는 1~256자, 선택한 `expiresAt`은 ISO 8601, `maxActivations`는 0 이상의 정수예요. 응답 본문은 64KB 이하예요. 2xx가 아니거나 응답 검증에 실패하거나 크기를 넘으면 라이선스를 pending으로 두고 재시도해요. 10초 안에 응답하고 같은 이벤트 ID에는 같은 키를 반환해요. - `/v1`은 기본적으로 서버 전용(CORS 미허용)이지만, 브라우저 CORS는 API 키 없는 라이선스 앱 `POST /v1/licenses/verify`, `activate`, `deactivate`와 키 교체 확인용 `GET /v1/licenses/signing-keys`를 허용해요. signing-keys는 API 키 없이 호출할 수 있고 모든 origin에 CORS를 허용하며 캐시할 수 있어요. 라이선스 앱 API에는 `productId`와 base64 또는 hex nonce(디코딩 후 16바이트 이상, 권장 형식은 32바이트를 hex 64자로 인코딩)가 필요하고, `Payri-Signature: kid=, ed25519=`로 원문 응답을 검증한 뒤 nonce와 5분 이내 `issuedAt`도 확인해요. 앱 API는 항상 HTTP 200이며 `status`는 `active | expired | revoked | pending | not_found`예요. 응답은 `{ status, expiresAt, maxActivations, activationCount, mode, activation, nonce, issuedAt }`이고 `maxActivations: null`은 무제한, `activation`은 활성화 객체 또는 `null`이에요. IP당 분당 60회와 라이선스 키당 분당 30회 제한 초과는 429 `LICENSE_RATE_LIMITED`예요. 같은 IP에서 10분 안에 `not_found`가 10회 나오면 해당 IP를 15분 차단하고 429 `LICENSE_LOOKUP_BLOCKED`를 반환해요. 두 429 모두 `Retry-After`를 반환해요. 400·429 오류에는 서명이 없고 404는 반환하지 않아요. 현재·다음 공개 키를 SDK 내장 테이블과 개발자 문서에 함께 게시하고 앱에 모두 내장해요. 고정된 키 목록에 없는 `kid`는 검증 실패로 처리하고 서버에서 키를 자동으로 받아오지 않아요. signing-keys 응답은 `{ keys: [{ kid, publicKey, createdAt }] }`이며 서명 키 교체 여부를 확인하는 용도예요. 다음 `kid` 공개 키를 먼저 게시한 뒤 SDK 배포와 앱 업데이트가 퍼질 때까지 수개월 기다리고, 앱 업데이트 보급을 기준으로 서버 서명을 새 `kid`로 전환해요. SDK 내장 테이블을 쓰면 SDK를 최신 버전으로 유지하고, 새 다음 키가 게시되면 앱을 업데이트해 미리 포함해요. 서명 전환 후 이전 키는 30일 동안 `signing-keys` 목록에 남아요. 이 30일은 앱 내장 키를 갱신할 유예 기간이 아니에요. 자동 발급 키는 `PAYRI-XXXXX-XXXXX-XXXXX-XXXXX`, test 키는 `PAYRI-TEST-XXXXX-XXXXX-XXXXX-XXXXX` 형식이에요. - test API 키로 만든 세션만 test 결제이며 공개 결제 링크는 live예요. 구독 세션은 `planId`와 `interval`을 필수로 보내고, `interval`은 `monthly` 또는 `yearly`예요. `planId`는 `GET /v1/products` 응답의 `pricing.plans[].id`에서 골라요. test 세션의 리턴 URL은 localhost와 127.0.0.1을 허용하고 live는 등록 호스트만 허용해요. 미등록 호스트는 400 `RETURN_URL_NOT_ALLOWED`, 미인증 공개 연락처는 409 `PUBLIC_CONTACT_VERIFICATION_REQUIRED`예요. PG 호출은 없고 테스트 화면에서 성공·실패·구독 갱신 실패를 선택해요. 전달물은 live와 같지만 test 데이터는 live 매출·재고·통계에 합산되지 않아요. 상품·쿠폰·스토어 설정은 공유되며 test 키가 노출된 공개 환경에서는 실제 상품이 무료 제공될 수 있어요. - 퀵스타트: 콘솔에서 API 키·웹훅·리디렉트 도메인을 설정하고 `PAYRI_API_KEY`·`PAYRI_WEBHOOK_SECRET`을 저장한 뒤 test 세션 생성 → 서명 검증과 이벤트 처리 → 라이브 키 전환 순서로 연동해요. - 사이트: https://docs.payri.kr - API base URL: https://api.payri.kr - 이 파일은 빌드 시 자동 생성됩니다. 각 섹션 머리말의 Source URL이 원본 페이지입니다. --- Source: https://docs.payri.kr/guide # 셀러 가이드 사업자 등록이나 서버 준비 없이 페이리로 디지털 상품 판매를 시작할 수 있어요. 상품을 올리고 링크를 공유하면 결제와 전달이 이어지고, 구매 확정된 주문은 정산을 요청할 수 있어요. ## 이렇게 써요 #### 노션 템플릿·PDF를 파는 크리에이터 SNS에 파일 결제 링크를 공유해요. 비공개 상품도 링크로 팔고, 결제 후 바로 다운로드해요.

쓰는 기능: 파일·링크 팔기 · 스토어 운영

#### 데스크톱 앱을 파는 인디 개발자 라이선스 키를 자동 발급하고 앱에서 검증해요. 활성화 기기 수도 제한할 수 있어요.

쓰는 기능: 라이선스 키 · 파일·링크 팔기

#### 월 구독 SaaS를 운영하는 1인 개발자 월·연 플랜을 사이트에 연결해요. 결제 완료 웹훅으로 권한을 주고, 구매자는 구독을 관리해요.

쓰는 기능: 구독 · 내 사이트에 결제 붙이기

#### 게임 키를 파는 인디 게임 팀 미리 발급한 게임 키 목록을 올리면 결제마다 하나씩 구매자에게 전달돼요.

쓰는 기능: 라이선스 키 · 파일·링크 팔기

#### 강의·커뮤니티 접근권 판매 접근 링크를 월 구독으로 판매하고, 상품별 환불 정책을 설정해요.

쓰는 기능: 파일·링크 팔기 · 구독 · 환불

#### 여러 상품을 한 스토어로 운영하는 셀러 공개 상품을 모아 쿠폰을 적용해요. 구매 확정된 주문은 정산을 요청해요.

쓰는 기능: 스토어 운영 · 결제와 주문 · 정산

## 먼저 알아둘 것 - 상품은 처음 '판매 중지' 상태예요. '판매중'으로 바꿔야 결제가 열려요. - 구매 확정되면 구매자는 환불을 신청할 수 없지만, 셀러는 정산에 넣기 전까지 직접 환불할 수 있어요. - 정산은 요청해야 시작되고, 정산에 들어간 주문은 환불할 수 없어요. - 핸들을 바꾸면 기존 URL은 404로 끊겨요. 공유한 링크를 갱신해야 해요. --- Source: https://docs.payri.kr/guide/sell-files # 파일·링크 팔기 파일, 비공개 링크, 접근권을 결제 링크 하나로 팔아요. ## 이런 식으로 동작해요 ![셀러가 파일이나 링크, 접근권을 상품으로 등록하고 결제 링크를 SNS·DM·스토어에 공유해요. 구매자는 링크를 열어 결제하고, 결제 즉시 파일과 링크를 받아요.](https://docs.payri.kr/image/guide/sell-files-flow.png) _결제 링크 하나로 판매부터 전달까지_ ## 이런 걸 팔 수 있어요 - 전자책·템플릿·프리셋 같은 파일을 페이리 보관이나 내 URL로 전달할 수 있어요. - 노션이나 드라이브의 비공개 링크를 판매할 수 있어요. - 강의나 커뮤니티 접근권을 판매할 수 있어요. - 파일과 보너스를 묶고 구성품을 표시할 수 있어요. - 비공개 상품이나 쿠폰 코드로 한정 판매할 수 있어요. - 상품을 스토어 페이지에 모아 보여줄 수 있어요. ## 설정은 여기서 - [상품 관리](https://docs.payri.kr/guide/products) — 파일과 링크를 상품에 담을 수 있어요. - [결제와 주문](https://docs.payri.kr/guide/payments) — 결제 링크와 주문 흐름을 확인할 수 있어요. - [스토어](https://docs.payri.kr/guide/store) — 판매 상품을 한곳에 모아 보여줄 수 있어요. --- Source: https://docs.payri.kr/guide/connect-site # 내 사이트에 결제 붙이기 내 서비스 안에서 결제하고, 결제가 끝나면 내 서버가 바로 권한을 열어요. ## 이런 식으로 동작해요 ![내 사이트의 결제 버튼이 페이리 결제창을 열어요. 결제가 완료되면 웹훅이 내 서버에 도착해 권한을 열고, 구매자는 내 사이트로 돌아와요.](https://docs.payri.kr/image/guide/connect-site-flow.png) _결제는 페이리가, 권한은 내 서버가_ ## 사이트 결제 연동 기능 - 로그인한 내 서비스 이용자에게 그 자리에서 상품을 판매할 수 있어요. - 일시불 상품과 구독 상품 모두 결제에 연결할 수 있어요. - 웹훅으로 받은 결제 결과를 내 데이터베이스에 반영할 수 있어요. - Test 키로 결제 연동을 미리 시험할 수 있어요. - API 키와 웹훅 시크릿을 콘솔에서 발급할 수 있어요. ## 설정은 여기서 - [퀵스타트](https://docs.payri.kr/developers/quickstart) — 내 서버에서 결제를 연결하는 흐름을 확인할 수 있어요. - [Test 모드](https://docs.payri.kr/developers/test-mode) — Test 결제로 연동을 미리 확인할 수 있어요. - [웹훅 이벤트](https://docs.payri.kr/developers/webhooks/events) — 받을 수 있는 결제 이벤트를 확인할 수 있어요. --- Source: https://docs.payri.kr/guide/subscriptions # 구독 월·연 구독으로 팔고, 갱신·변경·해지는 페이리가 처리해요. ## 이런 식으로 동작해요 ![첫 결제 뒤 월 또는 연 주기마다 자동 갱신돼요. 구매자는 플랜 변경(상위 플랜은 바로, 하위 플랜은 다음 결제일), 주기 전환, 해지를 직접 할 수 있어요.](https://docs.payri.kr/image/guide/subscriptions-flow.png) _갱신은 자동, 변경과 해지는 구매자가 직접_ ## 구독 기능 - 여러 구독 플랜을 만들 수 있어요. - 연 결제에 할인을 둘 수 있어요. - 상위 플랜 변경은 바로 적용하고 하위 플랜 변경은 다음 결제일에 적용할 수 있어요. - 갱신에 실패한 구독은 상태로 확인할 수 있어요. - 구독과 라이선스 키를 연결해 앱 이용을 관리할 수 있어요. - 내 사이트의 회원·권한 흐름에 구독을 연결할 수 있어요. ## 셀러가 바꿀 수 있는 범위 - 기존 구매자의 다음 결제 금액이 늘거나 새 결제가 생기는 변경은 셀러가 할 수 없어요. 상위 플랜을 무상으로 제공하거나, 금액이 늘지 않는 변경을 다음 결제 주기부터 예약할 수 있어요. - 가격 인상 동의와 즉시 과금 변경(유료 업그레이드·월간→연간 전환)은 구매자가 포털에서 이메일 인증번호를 확인한 뒤 직접 처리해요. - 셀러는 셀러나 API가 예약한 해지만 취소할 수 있어요. 구매자가 예약한 해지는 구매자만 취소할 수 있어요. - 갱신 결제 결과가 확인되지 않으면 다시 청구하지 않고 페이리가 확인한 뒤 처리해요. ## 설정은 여기서 - [상품 관리](https://docs.payri.kr/guide/products) — 구독 상품과 플랜을 구성할 수 있어요. - [구독 REST API](https://docs.payri.kr/developers/rest-api/subscriptions) — 구독을 내 서비스와 연결할 수 있어요. - [환불](https://docs.payri.kr/guide/refunds) — 구독 주문의 환불 흐름을 확인할 수 있어요. --- Source: https://docs.payri.kr/guide/licenses # 라이선스 키 결제 즉시 키를 내주고, 내 앱에서 그 키를 확인해요. ## 이런 식으로 동작해요 ![결제가 끝나면 발급 방식에 따라 키를 내줘요. 페이리 자동 발급 키는 구매자가 앱에 입력하고 앱이 페이리에 확인해 기기를 활성화해요. 미리 올린 키 목록은 키 하나를 소진해 그대로 전달하고, 내 서버 발급은 내 서버가 응답한 키를 그대로 전달해요.](https://docs.payri.kr/image/guide/licenses-flow.png) _발급 방식에 따라 전달 뒤 흐름이 달라요_ ## 라이선스 활용 - 데스크톱 앱이나 모바일 앱의 라이선스를 판매할 수 있어요. - 미리 준비한 게임 키 목록을 결제 뒤 하나씩 전달할 수 있어요. - 구매자별 기기 활성화 수를 제한할 수 있어요. - 구독형 앱의 라이선스 키를 구독과 연결할 수 있어요. - 전액 환불이 확정되면 라이선스 키가 자동 폐기돼요. - JavaScript SDK로 앱의 키 확인 흐름을 연결할 수 있어요. ## 설정은 여기서 - [라이선스 안내](https://docs.payri.kr/developers/licensing) — 라이선스 발급과 관리를 확인할 수 있어요. - [앱 연동 API](https://docs.payri.kr/developers/licensing/app-api) — 앱에서 키를 확인하고 기기를 관리할 수 있어요. - [JavaScript SDK](https://docs.payri.kr/developers/sdk/js) — JavaScript 앱에서 SDK를 사용할 수 있어요. --- Source: https://docs.payri.kr/guide/products # 상품 관리 상품을 등록할 때 셀러가 자주 걸리는 지점이에요. 특히 상품은 만들었다고 바로 팔리지 않아요. ![상품은 등록 직후 판매 중지 상태이고, 판매중으로 바꾸면 결제가 열려요. 다시 끄면 판매 중지로 돌아가요.](https://docs.payri.kr/image/guide/products-flow.png) _판매중으로 바꿔야 팔려요_ - **등록만으론 안 팔려요**: 상품은 처음 '판매 중지' 상태예요. '판매중'으로 바꿔야 결제가 열려요. - **환불 정책을 골라요**: 환불 불가·받기 전 환불 가능(7일)·7일 이내 환불 가능·직접 작성 중에서 골라요. 직접 작성은 환불 가능 기간을 7~90일로 정하며 기본값은 7일이에요. 구매자에게 결제 전에 보여줘요. - **상품 링크는 http·https만 써요**: 상품 이미지 URL과 전달물 링크는 http·https 주소만 받아요. ## 구독 가격 변경 구독 상품의 가격을 바꾸면 기존 구독자에게는 이렇게 적용돼요. 신규 구독자는 항상 바꾼 가격으로 결제해요. - 인하는 기존 구독자에게도 다음 갱신부터 적용돼요. - 인상은 기존 구독자에게 안내 메일이 나가고, 30일이 지난 뒤 첫 갱신부터 구매자가 이메일 인증 후 동의한 경우에만 적용돼요. 동의하지 않으면 그 갱신일에 구독이 끝나요. --- Source: https://docs.payri.kr/guide/payments # 결제와 주문 결제 동선과 주문 상태에서 헷갈리는 것들이에요. 구매자는 구매 확정 전까지 환불을 신청할 수 있고, 셀러는 정산에 넣기 전까지 환불할 수 있어요. ![결제 대기에서 결제 완료 또는 결제 실패로 나뉘고, 결제 완료 주문은 환불되거나 구매 확정돼요. 구매 확정된 주문은 셀러만 환불할 수 있고, 정산에 들어간 주문은 환불할 수 없어요.](https://docs.payri.kr/image/guide/payments-flow.png) _구매자 환불은 구매 확정까지, 셀러 환불은 정산 전까지_ - **비공개 (직접 링크만)**: 스토어엔 안 보이고 결제 링크 보유자만 구매할 수 있어요. SNS·DM 한정 판매용이에요. ## 구매자 본인 확인 구매 확정, 환불 요청 철회, 첫 다운로드, 라이선스 키 첫 확인은 구매자 본인만 할 수 있어요. 결제 직후 받은 주문 링크(영수증 메일 링크 포함)를 열거나 이메일 인증번호를 확인해 본인임을 인증해요. 셀러는 콘솔에서 이를 대신할 수 없어요. --- Source: https://docs.payri.kr/guide/refunds # 환불 환불은 상품에 지정된 정책 범위 안에서 전액 또는 부분으로 진행해요. 부분환불은 누적되다가 합계가 결제액에 닿으면 자동으로 전액 환불로 바뀌어요. ![결제 완료 주문에 부분 환불이 누적되다가 합계가 결제액에 닿으면 자동으로 전액 환불로 바뀌어요.](https://docs.payri.kr/image/guide/refunds-partial.png) _부분 환불은 누적되고 합계가 결제액이면 전액 환불_ ## 환불 기간과 구매 확정 | 정책(콘솔 표기) | 구매자 환불 | 자동 구매 확정 | |---|---|---| | 환불 불가 | 없음 | 받을 때, 늦어도 결제 7일 후 | | 받기 전 환불 가능(7일) | 결제 후 7일 안, 받기 전까지 | 받을 때, 늦어도 결제 7일 후 | | 7일 이내 환불 가능 | 결제 후 7일 안 | 결제 7일 후 | | 직접 작성 | 셀러가 정한 기간(7~90일) 안 | 그 기간이 지난 뒤 | 구독 주문은 해당 결제 주기가 끝나면 확정돼요. > [!WARNING] > 구매 확정된 주문은 구매자가 환불을 신청할 수 없어요. > > 셀러는 정산 신청에 포함되기 전까지 직접 환불할 수 있으며, 구매 확정 뒤 환불은 셀러 판단과 책임으로 진행돼요. 구매자의 환불 요청 철회와 구매 확정은 본인 확인 뒤에만 가능하고 셀러가 대신할 수 없어요. 자세한 내용은 [결제와 주문의 구매자 본인 확인](https://docs.payri.kr/guide/payments#구매자-본인-확인)을 확인해요. ## 승인형 환불 환불 정책을 직접 작성한 상품과 승인형으로 설정한 상품은 셀러가 승인해야 환불돼요. 접수 후 72시간 안에 처리하지 않으면 요청 금액 그대로 자동 승인돼요. --- Source: https://docs.payri.kr/guide/settlements # 정산 구매 확정된 주문을 골라 정산을 요청하면, PG 수수료를 뺀 금액이 셀러 계좌에 입금돼요. 페이리 수수료는 결제액에서 빼지 않고, 정산을 요청할 때 따로 결제해요. 정산을 요청하려면 본인인증과 본인 명의 계좌 인증을 먼저 마쳐요. ![결제 완료 주문이 구매 확정되면 정산을 요청하면서 정산 수수료를 결제하고, 14영업일 이내 심사 뒤 PG 수수료를 뺀 전액이 입금돼요. 반려되면 정산 수수료를 돌려받고, 정산 뒤에는 환불할 수 없어요.](https://docs.payri.kr/image/guide/settlements-flow.png) _구매 확정부터 입금까지_ ## 한눈에 - **구매 확정 시점**: 파일·링크·라이선스 키 상품은 결제 7일 뒤 자동으로 확정돼요. 구독은 결제 주기가 끝날 때 확정돼요. - **정산은 요청해야 시작**: 구매 확정된 주문만 요청할 수 있고, 요청하면 14영업일 이내에 심사 등이 모두 진행돼요. - **정산 수수료는 따로 결제**: 정산을 요청할 때 등급별 정액을 결제해요. 정산이 안 되면 환불돼요. - **정산 후 환불 불가**: 정산에 들어간 주문은 환불할 수 없어요. - **실지급액**: 결제액에서 PG 수수료 1%(VAT 별도)만 뺀 금액이에요. 페이리가 결제액에서 따로 떼는 수수료는 없어요. ## 셀러 등급 등급(일반·인증·우수)에 따라 구매 확정 후 보류 기간과 한도가 달라요. 본인인증과 계좌 인증을 마치고 거래가 쌓이면 등급이 올라가요. 내 등급과 기준은 콘솔의 내 등급·정산 정책 화면에서 볼 수 있어요. --- Source: https://docs.payri.kr/guide/store # 스토어 운영 `/s/:handle` 공개 스토어에서 핸들을 바꿀 때는 특히 조심해야 해요. 핸들을 바꾸면 기존 URL은 그대로 끊겨요(404). ![핸들을 바꾸면 기존 주소 /s/old-handle은 404로 끊기고 새 주소 /s/new-handle만 정상 노출돼요.](https://docs.payri.kr/image/guide/store-handle.png) _핸들 변경은 기존 링크를 끊어요_ - **핸들 형식**: 영문 소문자·숫자·하이픈만 쓸 수 있고 중복은 불가해요. - **브랜딩**: 브랜드 색상·로고는 스토어 설정에서 바꾸고, 스토어와 결제(/pay/:linkId) 페이지에 함께 반영돼요. --- Source: https://docs.payri.kr/developers # 개발자 문서 PayRi 연동은 [퀵스타트](https://docs.payri.kr/developers/quickstart)부터 시작해 테스트 결제와 웹훅을 확인해요. ## 연동 계약 요약 | 항목 | 계약 | |---|---| | 자격증명 | API 키 `pyr_live_…` 또는 `pyr_test_…`, 웹훅 시크릿 `whsec_…`예요. API 키는 서버에서만 사용하고, 웹훅 시크릿은 엔드포인트마다 관리해요. | | Base URL | `https://api.payri.kr`예요. API 경로는 `/v1/…`로 시작해요. | | 인증 | `Authorization: Bearer pyr_live_…` 형식으로 보내요. | | mode | 테스트는 `pyr_test_` 키로 만든 세션, 운영은 `pyr_live_` 키로 만든 세션을 사용해요. | ## 대표 결제 흐름 ```mermaid %%{init:{'theme':'base','themeVariables':{'primaryColor':'#eef2ff','primaryBorderColor':'#2e5bff','primaryTextColor':'#1e3a8a','lineColor':'#7c93ff','fontFamily':'inherit','textColor':'#1e3a8a','edgeLabelBackground':'#eef2ff','clusterBkg':'#f5f8ff','clusterBorder':'#c7d3ff'},'flowchart':{'curve':'basis','padding':16,'nodeSpacing':50,'rankSpacing':60}}}%% sequenceDiagram participant B as 구매자 participant A as 서비스 서버 participant P as PayRi B->>A: 구매 시작 A->>P: POST /v1/checkout/sessions (productId) P-->>A: Checkout Session url A-->>B: url로 이동 B->>P: 테스트 또는 실결제 P-->>A: order.paid 웹훅 A->>A: 서명 검증 · 이벤트 id 멱등 처리 P-->>B: successUrl로 복귀 A->>P: 필요하면 세션 또는 주문 조회 ``` 복귀 주소만으로 결제를 확정하지 말고, `order.paid` 서명을 검증해 이벤트 id로 중복을 제거하거나 서버에서 Checkout Session·주문을 조회해 확인해요. ## 문서 바로가기 - [퀵스타트](https://docs.payri.kr/developers/quickstart): Test 키로 결제 세션을 만들고 웹훅까지 확인해요. - [test 모드](https://docs.payri.kr/developers/test-mode): 테스트 결제와 운영 데이터의 차이를 확인해요. - [인증과 API 키](https://docs.payri.kr/developers/api-keys): API 키 발급, 보관, 회전 규칙을 확인해요. - [REST API](https://docs.payri.kr/developers/rest-api): 공통 규칙과 엔드포인트를 확인해요. - [웹훅](https://docs.payri.kr/developers/webhooks): 이벤트, 서명 검증, 재시도를 설정해요. - [라이선스 연동](https://docs.payri.kr/developers/licensing): 키 발급 방식과 앱 API를 연결해요. - [SDK](https://docs.payri.kr/developers/sdk): JavaScript·TypeScript에서 공식 SDK를 사용해요. - [운영 전환 체크리스트](https://docs.payri.kr/developers/go-live): Live 키와 엔드포인트를 배포 전에 점검해요. - [변경 이력](https://docs.payri.kr/developers/changelog): 이번 계약 개편과 문서 정정 항목을 확인해요. ## AI 코딩 도구로 연동하기 문서 사이트의 [llms.txt](https://docs.payri.kr/llms.txt)는 목차, [llms-full.txt](https://docs.payri.kr/llms-full.txt)는 전체 문서를 제공해요. 아래 프롬프트를 AI 코딩 도구에 붙여넣으면 `llms-full.txt`를 읽고 연동 코드를 작성해요. ```text https://docs.payri.kr/llms-full.txt 를 읽고 내 앱에 PayRi 결제를 연동해줘. 서버에서 Checkout Session을 만들어 결제창으로 보내고, 결제가 끝나면 successUrl로 돌아오게 해줘. order.paid 웹훅은 서명을 검증해서 받아줘. 먼저 test 키로 테스트 결제부터 웹훅 수신까지 확인해줘. API 키와 웹훅 시크릿은 환경변수 PAYRI_API_KEY, PAYRI_WEBHOOK_SECRET 에 있어. ``` --- Source: https://docs.payri.kr/developers/quickstart # 퀵스타트 Test 키로 결제를 만들고 웹훅으로 결과를 받는 순서예요. ## AI 코딩 도구로 시작하기 ```text https://docs.payri.kr/llms-full.txt 를 읽고 내 앱에 PayRi 결제를 연동해줘. 서버에서 Checkout Session을 만들어 결제창으로 보내고, 결제가 끝나면 successUrl로 돌아오게 해줘. order.paid 웹훅은 서명을 검증해서 받아줘. 먼저 test 키로 테스트 결제부터 웹훅 수신까지 확인해줘. API 키와 웹훅 시크릿은 환경변수 PAYRI_API_KEY, PAYRI_WEBHOOK_SECRET 에 있어. ``` ## 연동하기 **1. 키와 도메인 준비** 셀러 콘솔 **외부 연동**에서 `checkout:write` 권한의 Test API 키를 만들고, 한 번만 보이는 키를 `PAYRI_API_KEY`에 저장해요. 같은 메뉴에서 앱 도메인을 리디렉트 허용 목록에 등록해요. **2. 결제 세션 만들고 결제창으로 보내기** 서버에서 Checkout Session을 만들고 응답의 `url`로 구매자를 보내요. **cURL** ```bash curl -X POST https://api.payri.kr/v1/checkout/sessions \ -H "Authorization: Bearer $PAYRI_API_KEY" \ -H "Idempotency-Key: checkout-order-123" \ -H "Content-Type: application/json" \ -d '{ "productId": "prod_88x", "successUrl": "https://app.example.com/pay/complete", "cancelUrl": "https://app.example.com/cart", "metadata": { "accountId": "acct_123" } }' ``` **Node.js** ```js const response = await fetch('https://api.payri.kr/v1/checkout/sessions', { method: 'POST', headers: { Authorization: `Bearer ${process.env.PAYRI_API_KEY}`, 'Idempotency-Key': `checkout-${orderId}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ productId: 'prod_88x', successUrl: 'https://app.example.com/pay/complete', cancelUrl: 'https://app.example.com/cart', metadata: { accountId: 'acct_123' }, }), }) if (!response.ok) throw new Error(await response.text()) const session = await response.json() return Response.redirect(session.url, 302) ``` **SDK** ```ts import { Payri } from '@payri/sdk' const payri = new Payri({ apiKey: process.env.PAYRI_API_KEY! }) const session = await payri.checkout.sessions.create({ productId: 'prod_88x', successUrl: 'https://app.example.com/pay/complete', cancelUrl: 'https://app.example.com/cart', metadata: { accountId: 'acct_123' }, }, { idempotencyKey: `checkout-${orderId}` }) return Response.redirect(session.url, 302) ``` 구독 상품은 `planId`와 `interval`을 함께 보내요. 요청을 다시 보낼 때는 같은 `Idempotency-Key`를 써요. **3. 웹훅 받기** **외부 연동**에서 Test 웹훅 엔드포인트를 만들어 `order.paid`를 선택하고, 시크릿을 `PAYRI_WEBHOOK_SECRET`에 저장해요. **Node.js** ```js import express from 'express' import { Webhook } from 'standardwebhooks' const app = express() const webhook = new Webhook(process.env.PAYRI_WEBHOOK_SECRET) app.post('/webhooks/payri', express.raw({ type: 'application/json' }), async (req, res) => { let event try { event = webhook.verify(req.body, { 'webhook-id': req.get('webhook-id'), 'webhook-timestamp': req.get('webhook-timestamp'), 'webhook-signature': req.get('webhook-signature'), }) } catch { return res.sendStatus(400) } if (event.type === 'webhook.test') { return res.sendStatus(204) } if (event.type === 'order.paid') { // event.id를 UNIQUE 키로 저장하고, 처음 보는 id만 작업 큐에 넣어요. await enqueueOnce(event.id, event) } return res.sendStatus(202) }) ``` **SDK** ```ts import express from 'express' import { PayriWebhookError, Webhooks } from '@payri/sdk' const app = express() app.post('/webhooks/payri', express.raw({ type: 'application/json' }), async (req, res) => { let event try { event = await Webhooks.constructEvent({ payload: req.body, headers: req.headers, secret: process.env.PAYRI_WEBHOOK_SECRET!, }) } catch (error) { if (error instanceof PayriWebhookError) return res.sendStatus(400) throw error } if (event.type === 'webhook.test') { return res.sendStatus(204) } if (event.type === 'order.paid') { // event.id를 UNIQUE 키로 저장하고, 처음 보는 id만 작업 큐에 넣어요. await enqueueOnce(event.id, event) } return res.sendStatus(202) }) ``` 서명은 JSON으로 파싱하기 전 원본 본문으로 검증하고, 이벤트 `id`로 중복을 걸러요. **4. 테스트 결제로 확인** 결제창에서 **성공**을 고르면 `successUrl`에 `order_id`·`session_id`가 붙어 돌아오고, 웹훅으로 `order.paid`가 와요. 권한 부여 같은 처리는 복귀가 아니라 `order.paid`를 기준으로 해요. **5. Live 전환** Live 키와 Live 웹훅 엔드포인트를 따로 만들어 두 환경변수를 바꾸고, [운영 전환 체크리스트](https://docs.payri.kr/developers/go-live)를 확인해요. ## 막히면 | 증상 | 해결 | |---|---| | `400 RETURN_URL_NOT_ALLOWED` | 리디렉트 허용 목록에 앱 도메인을 등록해요. | | `400 VALIDATION_FAILED` | `Idempotency-Key`가 빠졌거나 1~255자를 벗어났어요. | | `403 API_KEY_SCOPE_MISSING` | API 키에 `checkout:write` 권한을 줘요. | | `409 IDEMPOTENCY_KEY_CONFLICT` | 같은 키를 다른 본문에 썼어요. 새 요청에는 새 키를 써요. | | `409 PUBLIC_CONTACT_VERIFICATION_REQUIRED` | 스토어 공개 연락처를 인증해요. | | 웹훅이 안 와요 | API 키와 웹훅 엔드포인트의 mode가 같은지, `order.paid`를 선택했는지 확인해요. | 그 밖의 오류는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. --- Source: https://docs.payri.kr/developers/test-mode # test 모드 Test 키로 만든 결제는 실제 돈이 오가지 않아요. ![Test 키로 결제하면 테스트 결제 화면에서 성공이나 실패를 골라요. 주문과 웹훅은 Test 데이터로 따로 쌓이고 정산·통계에는 반영되지 않아요. Live 키로 결제하면 실제 결제가 일어나고 정산·통계에 반영돼요. 상품·쿠폰·스토어 설정은 함께 쓰고, Test 결제도 전달물은 실제로 나가요.](https://docs.payri.kr/image/developers/test-live.png) _돈만 오가지 않고 흐름은 같아요_ | 항목 | Test | Live | |---|---|---| | API 키 | `pyr_test_…` | `pyr_live_…` | | 결제 승인 | 테스트 결제 화면에서 성공·실패를 골라요 | 실제 결제 | | 환불 | PG 호출 없이 처리 | 실제 환불 | | 리디렉트 호스트 | `localhost`·`127.0.0.1`은 등록 없이 사용, 그 밖의 호스트는 등록 | 등록한 호스트만, `localhost` 불가 | | 웹훅 | Test 엔드포인트로만 와요 | Live 엔드포인트로만 와요 | | 주문·구독·환불 데이터 | Test 데이터만 | Live 데이터만 | | 전달물 | 파일·링크는 실제 전달, 라이선스 키는 PAYRI-TEST- 키예요(내 서버 발급은 Test 엔드포인트로 요청해요). | 실제 전달 | | 구매자 화면·메일 | `[테스트]` 표시 | 표시 없음 | | 판매 수·재고·쿠폰 사용 횟수·정산·통계 | 반영 안 됨 | 반영 | | 상품·쿠폰·스토어 설정 | 공유 | 공유 | 공개 결제 링크는 항상 Live로 처리돼요. 구독 갱신은 셀러 콘솔 구독 상세의 **지금 갱신하기**로 바로 시험해요(test 구독만). 갱신 결과(성공·실패)는 테스트 결제 화면이나 콘솔에서 정해요. 셀러 콘솔에서는 Test/Live 스위치로 데이터를 바꿔 봐요. > [!WARNING] > Test 결제도 파일·링크 전달물은 실제로 나가요. > > Test 키와 스테이징 사이트는 외부에 공개하지 마세요. --- Source: https://docs.payri.kr/developers/api-keys # 인증과 API 키 REST API는 서버에서 `Authorization: Bearer` 헤더에 API 키를 넣어 호출해요. ```bash curl https://api.payri.kr/v1/orders \ -H "Authorization: Bearer $PAYRI_API_KEY" ``` > [!WARNING] > API 키를 브라우저나 앱에 넣지 마세요. > > 앱에서는 [라이선스 앱 API](https://docs.payri.kr/developers/licensing/app-api)를 써요. ## 키 형식 | 값 | 형식 | 용도 | |---|---|---| | API 키 | `pyr_live_…`, `pyr_test_…` | 인증 헤더에 넣어요. 발급할 때 한 번만 보여요. | | 키 ID | `key_…` | 콘솔과 로그에서 키를 구분해요. 인증에는 못 써요. | ## 발급 셀러 콘솔의 **외부 연동 → API 키**에서 새 키를 만들어요. ## 회전과 폐기 1. 셀러 콘솔에서 키를 회전해요. 2. 새 키를 서버에 배포해요. 옛 키는 기본 24시간 더 쓸 수 있어요. 3. 옛 키를 폐기해요. 유출이 의심되면 회전할 때 바로 폐기해요. 유예 중인 옛 키로 호출하면 응답에 `Payri-Key-Deprecated: <만료 시각>` 헤더가 와요. ## 인증 오류 | 코드 | HTTP | 의미 | |---|---|---| | `API_KEY_INVALID` | 401 | 인증 헤더가 없거나, 키가 올바르지 않거나, 회전 유예가 끝난 키예요. | | `API_KEY_IP_NOT_ALLOWED` | 403 | 요청 IP가 키의 허용 목록에 없어요. | | `API_KEY_SCOPE_MISSING` | 403 | 키에 필요한 권한이 없어요. `details.required`에 필요한 권한이 와요. | ## 다음 단계 - [권한](https://docs.payri.kr/developers/api-keys/scopes)에서 API별 최소 권한을 확인해요. - [IP · 상품 제한](https://docs.payri.kr/developers/api-keys/restrictions)에서 키 사용 범위를 설정해요. --- Source: https://docs.payri.kr/developers/api-keys/scopes # 권한 새 키에는 기본 권한이 없으므로 필요한 권한을 선택해요. ## 권한과 엔드포인트 | 권한 | 허용 엔드포인트 | |---|---| | `products:read` | `GET /v1/products`, `GET /v1/products/:id` | | `checkout:read` | `GET /v1/checkout/sessions/:id` | | `checkout:write` | `POST /v1/checkout/sessions` | | `orders:read` | `GET /v1/orders`, `GET /v1/orders/:id` | | `refunds:read` | `GET /v1/refund-requests`, `GET /v1/refund-requests/:id` | | `refunds:write` | `POST /v1/orders/:id/refund`, `POST /v1/refund-requests/:id/approve`, `POST /v1/refund-requests/:id/reject`, `POST /v1/refund-requests/:id/hold` | | `subscriptions:read` | `GET /v1/subscriptions`, `GET /v1/subscriptions/:id`, `GET /v1/subscriptions/:id/cancel-preview` | | `subscriptions:write` | `POST /v1/subscriptions/:id/cancel`, `POST /v1/portal-sessions` | | `licenses:read` | `GET /v1/licenses`, `GET /v1/licenses/:id` | 상품 범위 밖 응답은 [IP · 상품 제한](https://docs.payri.kr/developers/api-keys/restrictions)에서, 권한 부족은 [인증 오류](https://docs.payri.kr/developers/api-keys#인증-오류)에서 확인해요. > [!INFO] > 라이선스 앱 API의 `verify`, `activate`, `deactivate`는 API 키 대신 라이선스 키로 인증하므로 이 권한 표와 무관해요. --- Source: https://docs.payri.kr/developers/api-keys/restrictions # IP · 상품 제한 요청은 키 권한과 허용 IP·상품·mode 제한을 모두 통과해야 해요. ## 허용 IP 허용 목록이 비어 있으면 IP 제한이 없고, 미등록 IP는 `403 API_KEY_IP_NOT_ALLOWED`로 응답해요. IPv4는 단일 주소와 CIDR, IPv6는 CIDR 없이 단일 주소 완전 일치만 지원해요. | 표기 | 예시 | |---|---| | IPv4 주소 | `203.0.113.10` | | IPv4 CIDR | `203.0.113.0/24` | | IPv6 단일 주소 | `2001:db8::1` | ## 상품 범위 키 범위는 전체 상품 또는 지정 상품이며, 목록에는 허용된 상품의 리소스만 포함돼요. ## mode API 키는 발급 시 `test` 또는 `live` mode 하나에 고정되고 변경할 수 없으며, 데이터 분리는 [test 모드](https://docs.payri.kr/developers/test-mode)에서 확인해요. ## 오류 확인 | 상황 | 응답 | |---|---| | 요청 IP가 허용 목록에 없음 | `403 API_KEY_IP_NOT_ALLOWED` | | 요청에 필요한 권한이 없음 | `403 API_KEY_SCOPE_MISSING` | | 상품 범위 밖 리소스를 조회함 | 해당 리소스의 `NOT_FOUND` | 다른 인증 오류는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. --- Source: https://docs.payri.kr/developers/rest-api # REST API 개요 Base URL은 `https://api.payri.kr`이고 경로는 `/v1/...`로 시작하며 OpenAPI 명세는 [개발 문서](https://docs.payri.kr/openapi/v1.json)와 [운영 API](https://api.payri.kr/v1/openapi.json)에서 확인해요. ## 인증 요청은 `Authorization: Bearer <키>` 헤더로 인증하고 키 정보는 [인증과 API 키](https://docs.payri.kr/developers/api-keys)에서 확인하며, `/v1`은 서버 전용이라 브라우저에서는 [라이선스 앱 API](https://docs.payri.kr/developers/licensing/app-api)와 모든 origin에 CORS를 허용하는 API 키 없는 `GET /v1/licenses/signing-keys`를 호출해요. ## 공통 규칙 ### 멱등성 라이선스 앱 API를 제외한 모든 `POST /v1/...` 요청에는 `Idempotency-Key`가 필요해요. 키는 공백 제거 후 1~255자이며 스토어와 `mode` 범위에서 24시간 보관하고, HTTP 메서드·경로·본문으로 요청을 구분해요. | 상황 | 응답 | 할 일 | |---|---|---| | 키가 없거나 공백 제거 후 1~255자를 벗어남 | `400 VALIDATION_FAILED` (`details.fields[0].field`는 `Idempotency-Key`) | 키를 넣어 다시 보내요. | | 같은 키와 같은 요청 | 저장된 응답과 `Idempotent-Replayed: true` 헤더 | 저장된 응답을 사용해요. | | 같은 키와 다른 요청 | `409 IDEMPOTENCY_KEY_CONFLICT` | 새 요청에는 새 키를 사용해요. | | 같은 키의 요청이 처리 중 | `409 IDEMPOTENCY_IN_PROGRESS` | 잠시 후 같은 키와 같은 요청으로 다시 보내요. | | 검증 실패(`400 VALIDATION_FAILED`) 또는 5xx 응답 | 저장하거나 재생하지 않아요. | 같은 키와 요청으로 다시 보내면 새로 처리돼요. | | 네트워크 오류 또는 응답 시간 초과 | — | 같은 키로 재시도해요. | ### 요청과 응답 - 등록되지 않은 필드를 요청에 보내면 `400 VALIDATION_FAILED`가 와요. - 리소스 응답의 `mode`는 `"live"` 또는 `"test"`이고, 값이 없는 필드는 생략하지 않고 `null`로 와요. - 금액은 원 단위 정수이며 금액 필드와 함께 `currency`가 와요. ### 환경 분리 `live`·`test` API 키와 거래 데이터의 차이는 [test 모드](https://docs.payri.kr/developers/test-mode)에서 확인해요. ### 목록 `sort` 또는 `order`를 보내면 `400 VALIDATION_FAILED`가 와요. | 필드 | 타입 | 설명 | |---|---|---| | `page` | integer | 페이지 번호예요. 1부터 시작해요. | | `limit` | integer | 페이지 크기예요. 기본값은 20이고 최대 100이에요. | ```json { "data": [], "total": 0, "page": 1, "limit": 20 } ``` ### 호출 제한 API 키마다 분당 300회까지 호출할 수 있고, 한도를 넘으면 `429 RATE_LIMITED`와 초 단위 `Retry-After` 헤더가 돌아와요. ```http HTTP/1.1 429 Too Many Requests Retry-After: 12 Content-Type: application/json { "statusCode": 429, "code": "RATE_LIMITED", "message": "요청이 너무 많아요. 잠시 후 다시 시도해 주세요." } ``` ## 엔드포인트 키 권한은 [권한](https://docs.payri.kr/developers/api-keys/scopes), 상품 범위 밖 응답은 [IP · 상품 제한](https://docs.payri.kr/developers/api-keys/restrictions), 브라우저 라이선스 호출은 [라이선스 앱 API](https://docs.payri.kr/developers/licensing/app-api)에서 확인해요. ### 결제 세션 | 메서드 | 경로 | 설명 | 권한 | 멱등 | | --- | --- | --- | --- | --- | | POST | `/v1/checkout/sessions` | 결제 세션 생성 | `checkout:write` | 필수 | | GET | `/v1/checkout/sessions/{id}` | 결제 세션 조회 | `checkout:read` | — | ### 라이선스 | 메서드 | 경로 | 설명 | 권한 | 멱등 | | --- | --- | --- | --- | --- | | GET | `/v1/licenses` | 라이선스 목록 조회 | `licenses:read` | — | | GET | `/v1/licenses/{id}` | 라이선스 상세 조회 | `licenses:read` | — | ### 라이선스 앱 API | 메서드 | 경로 | 설명 | 권한 | 멱등 | | --- | --- | --- | --- | --- | | POST | `/v1/licenses/activate` | 라이선스 활성화 | API 키 불필요 | — | | POST | `/v1/licenses/deactivate` | 라이선스 비활성화 | API 키 불필요 | — | | GET | `/v1/licenses/signing-keys` | 라이선스 서명 공개키 조회 | API 키 불필요 | — | | POST | `/v1/licenses/verify` | 라이선스 검증 | API 키 불필요 | — | ### 명세 | 메서드 | 경로 | 설명 | 권한 | 멱등 | | --- | --- | --- | --- | --- | | GET | `/v1/openapi.json` | 명세 조회 | API 키 불필요 | — | ### 주문 | 메서드 | 경로 | 설명 | 권한 | 멱등 | | --- | --- | --- | --- | --- | | GET | `/v1/orders` | 주문 목록 조회 | `orders:read` | — | | GET | `/v1/orders/{id}` | 주문 상세 조회 | `orders:read` | — | | POST | `/v1/orders/{id}/refund` | 주문 환불 | `refunds:write` | 필수 | ### 포털 세션 | 메서드 | 경로 | 설명 | 권한 | 멱등 | | --- | --- | --- | --- | --- | | POST | `/v1/portal-sessions` | 구매자 포털 세션 생성 | `subscriptions:write` | 필수 | ### 상품 | 메서드 | 경로 | 설명 | 권한 | 멱등 | | --- | --- | --- | --- | --- | | GET | `/v1/products` | 상품 목록 조회 | `products:read` | — | | GET | `/v1/products/{id}` | 상품 상세 조회 | `products:read` | — | ### 환불 요청 | 메서드 | 경로 | 설명 | 권한 | 멱등 | | --- | --- | --- | --- | --- | | GET | `/v1/refund-requests` | 환불 요청 목록 조회 | `refunds:read` | — | | GET | `/v1/refund-requests/{id}` | 환불 요청 상세 조회 | `refunds:read` | — | | POST | `/v1/refund-requests/{id}/approve` | 환불 요청 승인 | `refunds:write` | 필수 | | POST | `/v1/refund-requests/{id}/hold` | 환불 요청 보류 | `refunds:write` | 필수 | | POST | `/v1/refund-requests/{id}/reject` | 환불 요청 거절 | `refunds:write` | 필수 | ### 구독 | 메서드 | 경로 | 설명 | 권한 | 멱등 | | --- | --- | --- | --- | --- | | GET | `/v1/subscriptions` | 구독 목록 조회 | `subscriptions:read` | — | | GET | `/v1/subscriptions/{id}` | 구독 상세 조회 | `subscriptions:read` | — | | POST | `/v1/subscriptions/{id}/cancel` | 구독 해지 | `subscriptions:write` | 필수 | | GET | `/v1/subscriptions/{id}/cancel-preview` | 구독 해지 미리보기 | `subscriptions:read` | — | --- Source: https://docs.payri.kr/developers/rest-api/checkout # Checkout Checkout Session을 발급하면 구매자를 결제 페이지로 보낼 URL을 받을 수 있어요. #### 엔드포인트 - [POST /v1/checkout/sessions](https://docs.payri.kr/developers/rest-api/checkout#세션-생성) - [GET /v1/checkout/sessions/{id}](https://docs.payri.kr/developers/rest-api/checkout#세션-조회) ## 세션 생성 **POST https://api.payri.kr/v1/checkout/sessions** 상품 식별자로 test 또는 live 결제 세션을 만들어요. 공개 연락처 인증이 끝나지 않은 스토어는 409 응답을 돌려줘요. Idempotency-Key는 24시간 보관해요. API 키 인증과 `checkout:write` 권한이 필요해요. `Idempotency-Key` 헤더가 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `Idempotency-Key (header)` | `string` | 예 | POST 요청의 중복 실행을 막는 키예요. 24시간 보관하며 본문이 다른 요청에는 같은 키를 쓸 수 없어요. | 1~255자 | | #### 요청 본문 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `cancelUrl` | `string` | 아니요 | 결제가 취소된 뒤 구매자를 돌려보낼 URL이에요. | | `https://shop.example.com/cancel` | | `customer` | `object` | 아니요 | 구매자 사전 입력 정보예요. | | | | `customer.email` | `string` | 아니요 | 결제 구매자의 이메일 주소예요. | | `buyer@example.com` | | `customer.name` | `string` | 아니요 | 결제 구매자의 이름이에요. | | `홍길동` | | `customer.phone` | `string` | 아니요 | 결제 구매자의 연락처예요. | | `01012345678` | | `interval` | `string` | 아니요 | 구독 상품의 결제 주기예요. | `monthly` · `yearly` | `monthly` | | `metadata` | `object` | 아니요 | 결제 후 웹훅과 조회 응답에서 받을 문자열 메타데이터예요. | | `{"campaign":"fall"}` | | `planId` | `string` | 아니요 | 구독 상품의 플랜 식별자예요. | | `plan_basic` | | `productId` | `string` | 예 | 결제할 상품 식별자예요. | | `prod_123` | | `successUrl` | `string` | 아니요 | 결제 완료 후 구매자를 돌려보낼 URL이에요. | | `https://shop.example.com/complete` | #### 요청 예시 ```bash curl -X POST https://api.payri.kr/v1/checkout/sessions \ -H "Authorization: Bearer $PAYRI_API_KEY" \ -H "Idempotency-Key: checkout-prod-123-001" \ -H "Content-Type: application/json" \ -d '{ "productId": "prod_123", "successUrl": "https://shop.example.com/complete", "customer": { "email": "buyer@example.com" } }' ``` 응답: HTTP 201, [결제 세션 객체](https://docs.payri.kr/developers/rest-api/checkout#결제-세션-객체) #### 성공 응답 예시 ```json { "cancelUrl": "https://shop.example.com/cancel", "createdAt": "2026-10-15T00:00:00.000Z", "customer": { "email": "buyer@example.com", "name": "홍길동", "phone": null }, "expiresAt": "2026-10-15T00:30:00.000Z", "id": "cs_123", "metadata": { "campaign": "fall" }, "mode": "test", "orderId": null, "productId": "prod_123", "status": "open", "successUrl": "https://shop.example.com/complete", "url": "https://payri.kr/checkout/cs_123" } ``` #### 오류 코드 | 코드 | HTTP | 의미 | | --- | ---: | --- | | `PRODUCT_NOT_FOUND` | 404 | 상품이 없거나 키의 상품 범위 밖이에요. | | `PRODUCT_ARCHIVED` | 409 | 보관된 상품은 결제할 수 없어요. | | `SOLD_OUT` | 409 | 이 상품은 품절되어 결제할 수 없어요. | | `NOT_STARTED` | 409 | 아직 판매가 시작되지 않은 상품이에요. | | `ENDED` | 409 | 판매가 종료된 상품이에요. | | `PUBLIC_CONTACT_VERIFICATION_REQUIRED` | 409 | Checkout 세션을 만들기 전에 공개 연락처를 인증해 주세요. | | `CHECKOUT_URL_INVALID` | 400 | Checkout 또는 포털 복귀 URL 형식이 올바르지 않아요. | | `RETURN_URL_NOT_ALLOWED` | 400 | 복귀 URL 도메인이 결제 모드별 허용 목록에 없어요. | | `METADATA_INVALID` | 400 | 메타데이터가 평면 문자열 맵이 아니거나 허용된 크기를 벗어났어요. | | `CHECKOUT_NOT_CONFIGURED` | 500 | 스토어의 결제 설정이 완료되지 않아 Checkout 세션을 만들 수 없어요. | 표에 없는 공통 오류(인증·권한, 호출 한도, 입력 검증, 멱등 키, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. **알아둘 점** - `successUrl`·`cancelUrl` 호스트는 리디렉트 허용 목록에 등록해요. - 결제가 끝나면 `successUrl`에 `order_id`와 `session_id`가 붙어 돌아와요. 이 값만으로 확정하지 말고 세션을 조회하거나 `order.paid` 웹훅을 확인해요. - 구매자가 결제를 취소하거나 PG 결제가 실패하면 `cancelUrl`에 `session_id`가 붙어 돌아와요. - `metadata`는 문자열 값만 받고 키 50개, 키 40자, 값 500자까지예요. - 같은 구매자가 같은 상품을 다시 구독 결제하면 구독이 하나 더 생겨요. 중복을 막으려면 세션을 만들기 전에 확인해요. ## 세션 조회 **GET https://api.payri.kr/v1/checkout/sessions/{id}** 세션 식별자로 만료 시각과 결제 상태를 조회해요. API 키 인증과 `checkout:read` 권한이 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `id (path)` | `string` | 예 | 조회할 결제 세션 식별자예요. | | `cs_123` | #### 요청 예시 ```bash curl https://api.payri.kr/v1/checkout/sessions/cs_123 \ -H "Authorization: Bearer $PAYRI_API_KEY" ``` 응답: HTTP 200, [결제 세션 객체](https://docs.payri.kr/developers/rest-api/checkout#결제-세션-객체) #### 성공 응답 예시 ```json { "cancelUrl": "https://shop.example.com/cancel", "createdAt": "2026-10-15T00:00:00.000Z", "customer": { "email": "buyer@example.com", "name": "홍길동", "phone": null }, "expiresAt": "2026-10-15T00:30:00.000Z", "id": "cs_123", "metadata": { "campaign": "fall" }, "mode": "test", "orderId": null, "productId": "prod_123", "status": "open", "successUrl": "https://shop.example.com/complete", "url": "https://payri.kr/checkout/cs_123" } ``` #### 오류 코드 | 코드 | HTTP | 의미 | | --- | ---: | --- | | `CHECKOUT_SESSION_NOT_FOUND` | 404 | Checkout 세션이 없거나 키의 범위 밖이에요. | 표에 없는 공통 오류(인증·권한, 호출 한도, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. ## 결제 세션 객체 ### 결제 세션 객체 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `cancelUrl` | `string \| null` | 예 | 결제 취소 후 이동할 URL이에요. | | `null` | | `createdAt` | `string` | 예 | 세션이 생성된 시각이에요. | | `2026-10-15T00:00:00.000Z` | | `customer` | `object` | 예 | 세션에 사전 입력한 구매자 정보예요. | | | | `customer.email` | `string \| null` | 예 | 결제 구매자의 이메일 주소예요. | | `buyer@example.com` | | `customer.name` | `string \| null` | 예 | 결제 구매자의 이름이에요. | | `홍길동` | | `customer.phone` | `string \| null` | 예 | 결제 구매자의 연락처예요. | | `01012345678` | | `expiresAt` | `string` | 예 | 세션 생성 후 30분이 되는 만료 시각이에요. | | `2026-10-15T00:30:00.000Z` | | `id` | `string` | 예 | 결제 세션 식별자예요. | | `cs_123` | | `metadata` | `object \| null` | 예 | 요청 때 전달한 메타데이터예요. | | `{"campaign":"fall"}` | | `mode` | `string` | 예 | test 또는 live 결제 환경을 알려줘요. | `live` · `test` | `test` | | `orderId` | `string \| null` | 예 | 결제가 완료된 주문 식별자예요. | | `null` | | `productId` | `string` | 예 | 상품 식별자예요. | | `prod_123` | | `status` | `string` | 예 | 결제 세션 상태예요. | `open` · `completed` · `expired` | `open` | | `successUrl` | `string \| null` | 예 | 결제 완료 후 이동할 URL이에요. | | `null` | | `url` | `string` | 예 | 구매자가 열 결제 페이지 URL이에요. | | `https://payri.kr/checkout/cs_123` | --- Source: https://docs.payri.kr/developers/rest-api/products # Products 결제에 사용할 상품과 가격·플랜 정보를 조회해요. #### 엔드포인트 - [GET /v1/products](https://docs.payri.kr/developers/rest-api/products#상품-목록) - [GET /v1/products/{id}](https://docs.payri.kr/developers/rest-api/products#상품-단건) ## 상품 목록 **GET https://api.payri.kr/v1/products** API 키의 test 또는 live 환경과 공개 상품 범위에 속한 상품을 페이지 단위로 조회해요. API 키 인증과 `products:read` 권한이 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `page (query)` | `number` | 아니요 | 조회할 페이지 번호예요. | 1 이상 | `1` | | `limit (query)` | `number` | 아니요 | 페이지마다 조회할 항목 수예요. | 1~100 | `20` | | `status (query)` | `string` | 아니요 | 상품 상태로 결과를 좁혀요. | `draft` · `active` · `archived` | `active` | #### 요청 예시 ```bash curl 'https://api.payri.kr/v1/products?page=1&limit=20&status=active' \ -H "Authorization: Bearer $PAYRI_API_KEY" ``` 응답: HTTP 200, [상품 객체](https://docs.payri.kr/developers/rest-api/products#상품-객체) 목록, 형식 `{ data, total, page, limit }` #### 성공 응답 예시 ```json { "data": [ { "contents": [ { "label": "가이드 PDF", "type": "file" } ], "createdAt": "2026-10-01T00:00:00.000Z", "deliverable": { "fileName": "guide.pdf", "fileSize": 102400, "licenseIssuance": null, "maxActivations": null, "note": null, "type": "file" }, "description": "상품 상세 설명이에요.", "displayOrder": 0, "icon": "", "iconColor": null, "id": "prod_123", "imageUrl": null, "linkId": "link_123", "listed": true, "mode": "test", "name": "디지털 가이드", "pricing": { "amount": 10000, "currency": "KRW", "interval": null, "plans": null, "type": "one_time" }, "refundPolicy": { "customText": null, "refundDays": null, "requireApproval": false, "subscription": null, "type": "within_7days" }, "saleEndsAt": null, "saleStartsAt": null, "status": "active", "stockLimit": null, "summary": "바로 내려받는 가이드예요." } ], "limit": 20, "page": 1, "total": 1 } ``` 표에 없는 공통 오류(인증·권한, 호출 한도, 입력 검증, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. ## 상품 단건 **GET https://api.payri.kr/v1/products/{id}** 상품 식별자로 가격과 전달 정보를 조회해요. API 키 인증과 `products:read` 권한이 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `id (path)` | `string` | 예 | 조회할 상품 식별자예요. | | `prod_123` | #### 요청 예시 ```bash curl https://api.payri.kr/v1/products/prod_123 \ -H "Authorization: Bearer $PAYRI_API_KEY" ``` 응답: HTTP 200, [상품 객체](https://docs.payri.kr/developers/rest-api/products#상품-객체) #### 성공 응답 예시 ```json { "contents": [ { "label": "가이드 PDF", "type": "file" } ], "createdAt": "2026-10-01T00:00:00.000Z", "deliverable": { "fileName": "guide.pdf", "fileSize": 102400, "licenseIssuance": null, "maxActivations": null, "note": null, "type": "file" }, "description": "상품 상세 설명이에요.", "displayOrder": 0, "icon": "", "iconColor": null, "id": "prod_123", "imageUrl": null, "linkId": "link_123", "listed": true, "mode": "test", "name": "디지털 가이드", "pricing": { "amount": 10000, "currency": "KRW", "interval": null, "plans": null, "type": "one_time" }, "refundPolicy": { "customText": null, "refundDays": null, "requireApproval": false, "subscription": null, "type": "within_7days" }, "saleEndsAt": null, "saleStartsAt": null, "status": "active", "stockLimit": null, "summary": "바로 내려받는 가이드예요." } ``` #### 오류 코드 | 코드 | HTTP | 의미 | | --- | ---: | --- | | `PRODUCT_NOT_FOUND` | 404 | 상품이 없거나 키의 상품 범위 밖이에요. | 표에 없는 공통 오류(인증·권한, 호출 한도, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. **알아둘 점** - 구독 Checkout Session의 `planId`에는 `pricing.plans[].id`를 보내요. ## 상품 객체 ### 상품 객체 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `contents` | `array of object` | 예 | 상품에 포함된 콘텐츠예요. | | | | `contents[].label` | `string` | 예 | 콘텐츠 항목의 표시 이름이에요. | | `강의 자료` | | `contents[].type` | `string` | 예 | 콘텐츠 항목의 유형이에요. | `file` · `license` · `access` · `service` · `support` · `update` · `guide` · `other` | `file` | | `createdAt` | `string` | 예 | 상품이 생성된 시각이에요. | | `2026-10-15T00:00:00.000Z` | | `deliverable` | `object` | 예 | 상품 전달 정보예요. | | | | `deliverable.fileName` | `string \| null` | 예 | 전달 파일 이름이에요. | | `null` | | `deliverable.fileSize` | `number \| null` | 예 | 전달 파일 크기를 바이트로 알려줘요. | | `null` | | `deliverable.licenseIssuance` | `string \| null` | 예 | 라이선스 발급 방식이에요. | `payri` · `seller_server` · `pool` · `shared` · `manual` · `null` | `payri` | | `deliverable.maxActivations` | `number \| null` | 예 | 라이선스 최대 활성화 수예요. 무제한이면 null이에요. | | `null` | | `deliverable.note` | `string \| null` | 예 | 전달 방식에 대한 안내예요. | | `null` | | `deliverable.type` | `string` | 예 | 상품 전달 유형이에요. | `file` · `license` · `link` | `file` | | `description` | `string` | 예 | 상품 상세 설명이에요. | | `상품 상세 설명이에요.` | | `displayOrder` | `number \| null` | 예 | 스토어에서 상품 표시 순서예요. | | `0` | | `icon` | `string` | 예 | 상품 아이콘 주소예요. | | `` | | `iconColor` | `string \| null` | 예 | 아이콘 배경 색상 코드예요. | | `null` | | `id` | `string` | 예 | 상품 식별자예요. | | `prod_123` | | `imageUrl` | `string \| null` | 예 | 상품 이미지 주소예요. | | `null` | | `linkId` | `string` | 예 | 결제 페이지 링크 식별자예요. | | `link_123` | | `listed` | `boolean` | 예 | 상품이 공개 판매 중인지 알려줘요. | | `true` | | `mode` | `string` | 예 | test 또는 live 상품 환경을 알려줘요. | `live` · `test` | `live` | | `name` | `string` | 예 | 상품 이름이에요. | | `디지털 가이드` | | `pricing` | `object` | 예 | 상품 가격과 구독 플랜 정보예요. | | | | `pricing.amount` | `number \| null` | 예 | 단건 상품의 결제 금액이에요. | | `5000` | | `pricing.currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `pricing.interval` | `string \| null` | 예 | 단건 상품의 결제 주기예요. | `monthly` · `yearly` · `null` | `null` | | `pricing.plans` | `array of object \| null` | 예 | 구독 상품의 플랜 목록이에요. | | | | `pricing.plans[].currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `pricing.plans[].features` | `array of string \| null` | 예 | 플랜에 포함된 기능 목록이에요. | | `["다운로드 가능"]` | | `pricing.plans[].id` | `string` | 예 | 플랜 식별자예요. | | `plan_basic` | | `pricing.plans[].monthlyAmount` | `number` | 예 | 월간 결제 금액을 원 단위로 알려줘요. | | `9900` | | `pricing.plans[].name` | `string` | 예 | 플랜 이름이에요. | | `Basic` | | `pricing.plans[].tagline` | `string \| null` | 예 | 플랜 요약 문구예요. | | `null` | | `pricing.plans[].tier` | `number` | 예 | 플랜 표시 순서예요. | | `1` | | `pricing.plans[].yearlyAmount` | `number \| null` | 예 | 연간 결제 금액을 원 단위로 알려줘요. | | `99000` | | `pricing.type` | `string` | 예 | 가격 유형이에요. | `one_time` · `subscription` | `subscription` | | `refundPolicy` | `object` | 예 | 상품 환불 정책이에요. | | | | `refundPolicy.customText` | `string \| null` | 예 | 사용자에게 표시할 환불 안내예요. | | `null` | | `refundPolicy.refundDays` | `integer \| null` | 예 | custom 정책의 결제 후 환불 요청 가능 일수예요. 이후 자동 구매확정돼요. | 7~90 | `null` | | `refundPolicy.requireApproval` | `boolean \| null` | 예 | 환불 요청에 판매자 승인이 필요한지 알려줘요. | | `false` | | `refundPolicy.subscription` | `object \| null` | 예 | 구독 상품의 환불 조건이에요. | | | | `refundPolicy.subscription.dayRounding` | `string \| null` | 예 | 환불 일수 계산 방식이에요. | `ceil` · `floor` · `null` | `ceil` | | `refundPolicy.subscription.initialFullRefundDays` | `number \| null` | 예 | 최초 결제의 전액 환불 가능 일수예요. | | `7` | | `refundPolicy.subscription.penalty` | `object \| null` | 예 | 환불 위약금 조건이에요. | | | | `refundPolicy.subscription.penalty.currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `refundPolicy.subscription.penalty.fixedAmount` | `number \| null` | 예 | 고정 위약금을 원 단위로 알려줘요. | | `0` | | `refundPolicy.subscription.penalty.rateBps` | `number \| null` | 예 | 위약금 비율을 1만분율로 알려줘요. | | `0` | | `refundPolicy.subscription.renewalFullRefundDays` | `number \| null` | 예 | 갱신 결제의 전액 환불 가능 일수예요. | | `7` | | `refundPolicy.subscription.restrictRepeatFullRefund` | `boolean \| null` | 예 | 반복 전액 환불 제한 여부예요. | | `false` | | `refundPolicy.subscription.yearlyDiscountClawback` | `boolean \| null` | 예 | 연간 할인 환수 여부예요. | | `false` | | `refundPolicy.type` | `string \| null` | 예 | 환불 정책 유형이에요. before_delivery는 제공 개시 전·결제 후 7일 이내에만 환불 요청할 수 있고, 제공 개시 시 즉시 또는 미제공 상태로 7일이 지나면 자동 구매확정돼요. | `no_refund` · `before_delivery` · `within_7days` · `custom` · `null` | `within_7days` | | `saleEndsAt` | `string \| null` | 예 | 판매 종료 시각이에요. | | `null` | | `saleStartsAt` | `string \| null` | 예 | 판매 시작 시각이에요. | | `null` | | `status` | `string` | 예 | 상품 상태예요. | `draft` · `active` · `archived` | `active` | | `stockLimit` | `number \| null` | 예 | 재고 상한이에요. | | `null` | | `summary` | `string` | 예 | 상품 한 줄 소개예요. | | `바로 내려받는 가이드예요.` | --- Source: https://docs.payri.kr/developers/rest-api/orders # Orders 주문 목록과 단건을 조회해요. #### 엔드포인트 - [GET /v1/orders](https://docs.payri.kr/developers/rest-api/orders#주문-목록) - [GET /v1/orders/{id}](https://docs.payri.kr/developers/rest-api/orders#주문-단건) ## 주문 목록 **GET https://api.payri.kr/v1/orders** API 키의 test 또는 live 환경과 상품 범위에 속한 주문을 페이지 단위로 조회해요. API 키 인증과 `orders:read` 권한이 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `page (query)` | `number` | 아니요 | 조회할 페이지 번호예요. | 1 이상 | `1` | | `limit (query)` | `number` | 아니요 | 페이지마다 조회할 항목 수예요. | 1~100 | `20` | | `buyerEmail (query)` | `string` | 아니요 | 구매자 이메일로 주문을 찾아요. | 이메일 | `buyer@example.com` | | `status (query)` | `string` | 아니요 | 주문 상태로 결과를 좁혀요. | `pending` · `paid` · `failed` · `refunded` | `paid` | | `productId (query)` | `string` | 아니요 | 상품 식별자로 주문을 좁혀요. | | `prod_123` | | `checkoutSessionId (query)` | `string` | 아니요 | 결제 세션 식별자로 주문을 좁혀요. | | `cs_123` | | `createdFrom (query)` | `string` | 아니요 | 이 시각 이후 생성된 주문을 조회해요. | ISO 8601 날짜시간 | `2026-10-01T00:00:00.000Z` | | `createdTo (query)` | `string` | 아니요 | 이 시각 이전 생성된 주문을 조회해요. | ISO 8601 날짜시간 | `2026-10-15T00:00:00.000Z` | #### 요청 예시 ```bash curl 'https://api.payri.kr/v1/orders?page=1&limit=20&status=paid' \ -H "Authorization: Bearer $PAYRI_API_KEY" ``` 응답: HTTP 200, [주문 객체](https://docs.payri.kr/developers/rest-api/orders#주문-객체) 목록, 형식 `{ data, total, page, limit }` #### 성공 응답 예시 ```json { "data": [ { "amount": 10000, "billingInterval": null, "buyerEmail": "buyer@example.com", "buyerName": "홍길동", "buyerPhone": "01012345678", "checkoutSessionId": "cs_123", "confirmedAt": null, "coupon": null, "createdAt": "2026-10-15T00:00:00.000Z", "currency": "KRW", "id": "ord_123", "metadata": { "campaign": "fall" }, "method": "card", "mode": "test", "paidAt": "2026-10-15T00:01:00.000Z", "periodEnd": null, "periodStart": null, "planId": null, "productId": "prod_123", "productName": "디지털 가이드", "refundedAmount": 0, "refundedAt": null, "refunds": [], "status": "paid", "subscriptionEvent": null, "subscriptionId": null, "withdrawalNoticeAgreedAt": "2026-10-15T00:00:00.000Z" } ], "limit": 20, "page": 1, "total": 1 } ``` 표에 없는 공통 오류(인증·권한, 호출 한도, 입력 검증, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. ## 주문 단건 **GET https://api.payri.kr/v1/orders/{id}** 주문 식별자로 상세 정보와 환불 내역을 조회해요. API 키 인증과 `orders:read` 권한이 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `id (path)` | `string` | 예 | 조회할 주문 식별자예요. | | `ord_123` | #### 요청 예시 ```bash curl https://api.payri.kr/v1/orders/ord_123 \ -H "Authorization: Bearer $PAYRI_API_KEY" ``` 응답: HTTP 200, [주문 객체](https://docs.payri.kr/developers/rest-api/orders#주문-객체) #### 성공 응답 예시 ```json { "amount": 10000, "billingInterval": null, "buyerEmail": "buyer@example.com", "buyerName": "홍길동", "buyerPhone": "01012345678", "checkoutSessionId": "cs_123", "confirmedAt": null, "coupon": null, "createdAt": "2026-10-15T00:00:00.000Z", "currency": "KRW", "id": "ord_123", "metadata": { "campaign": "fall" }, "method": "card", "mode": "test", "paidAt": "2026-10-15T00:01:00.000Z", "periodEnd": null, "periodStart": null, "planId": null, "productId": "prod_123", "productName": "디지털 가이드", "refundedAmount": 0, "refundedAt": null, "refunds": [], "status": "paid", "subscriptionEvent": null, "subscriptionId": null, "withdrawalNoticeAgreedAt": "2026-10-15T00:00:00.000Z" } ``` #### 오류 코드 | 코드 | HTTP | 의미 | | --- | ---: | --- | | `ORDER_NOT_FOUND` | 404 | 주문이 없거나 키의 상품 범위 밖이에요. | 표에 없는 공통 오류(인증·권한, 호출 한도, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. **알아둘 점** - 구독 결제의 `subscriptionEvent` 값은 [웹훅 이벤트](https://docs.payri.kr/developers/webhooks/events)에서 확인해요. - `withdrawalNoticeAgreedAt`은 청약철회 제한 고지 동의 시각이며, 비동의 또는 미기록이면 `null`이에요. - 상품 범위 밖 주문 응답은 [IP · 상품 제한](https://docs.payri.kr/developers/api-keys/restrictions)에서 확인해요. ## 주문 객체 ### 주문 객체 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `amount` | `number` | 예 | 결제 금액을 원 단위 정수로 알려줘요. | | `10000` | | `billingInterval` | `string \| null` | 예 | 구독 청구 주기예요. | `monthly` · `yearly` · `null` | `monthly` | | `buyerEmail` | `string` | 예 | 구매자 이메일 주소예요. | | `buyer@example.com` | | `buyerName` | `string` | 예 | 구매자 이름이에요. | | `홍길동` | | `buyerPhone` | `string` | 예 | 구매자 연락처예요. | | `01012345678` | | `checkoutSessionId` | `string \| null` | 예 | 연결된 결제 세션 식별자예요. | | `cs_123` | | `confirmedAt` | `string \| null` | 예 | 구매 확정 시각이에요. | | `null` | | `coupon` | `object \| null` | 예 | 적용한 쿠폰 정보예요. | | | | `coupon.code` | `string` | 예 | 쿠폰 코드를 알려줘요. | | `WELCOME10` | | `coupon.currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `coupon.discountAmount` | `number` | 예 | 할인 금액을 원 단위로 알려줘요. | | `1000` | | `coupon.name` | `string` | 예 | 쿠폰 이름을 알려줘요. | | `신규 고객 할인` | | `coupon.originalAmount` | `number` | 예 | 할인 전 금액을 원 단위로 알려줘요. | | `10000` | | `createdAt` | `string` | 예 | 주문이 생성된 시각이에요. | | `2026-10-15T00:00:00.000Z` | | `currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `id` | `string` | 예 | 주문 식별자예요. | | `ord_123` | | `metadata` | `object \| null` | 예 | 요청 때 전달한 메타데이터예요. | | `{"campaign":"fall"}` | | `method` | `string` | 예 | 결제 수단이에요. | `card` · `kakao_pay` · `naver_pay` · `toss` | `card` | | `mode` | `string` | 예 | test 또는 live 결제 환경을 알려줘요. | `live` · `test` | `test` | | `paidAt` | `string \| null` | 예 | 결제가 완료된 시각이에요. | | `2026-10-15T00:01:00.000Z` | | `periodEnd` | `string \| null` | 예 | 해당 결제 기간의 종료 시각이에요. | | `null` | | `periodStart` | `string \| null` | 예 | 해당 결제 기간의 시작 시각이에요. | | `null` | | `planId` | `string \| null` | 예 | 구독 플랜 식별자예요. | | `null` | | `productId` | `string` | 예 | 상품 식별자예요. | | `prod_123` | | `productName` | `string` | 예 | 구매한 상품 이름이에요. | | `디지털 가이드` | | `refundedAmount` | `number \| null` | 예 | 누적 환불 금액을 원 단위로 알려줘요. | | `0` | | `refundedAt` | `string \| null` | 예 | 환불이 완료된 시각이에요. | | `null` | | `refunds` | `array of object` | 예 | 주문에 연결된 환불 내역이에요. | | | | `refunds[].amount` | `number` | 예 | 환불 금액을 원 단위로 알려줘요. | | `5000` | | `refunds[].at` | `string` | 예 | 환불이 처리된 시각이에요. | | `2026-10-15T00:00:00.000Z` | | `refunds[].currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `refunds[].reason` | `string \| null` | 예 | 환불 사유를 알려줘요. | | `고객 요청` | | `status` | `string` | 예 | 주문 상태예요. | `pending` · `paid` · `failed` · `refunded` | `paid` | | `subscriptionEvent` | `string \| null` | 예 | 결제가 발생한 구독 이벤트예요. | `initial` · `renewal` · `proration` · `null` | `initial` | | `subscriptionId` | `string \| null` | 예 | 연결된 구독 식별자예요. | | `null` | | `withdrawalNoticeAgreedAt` | `string \| null` | 예 | 청약 철회 안내 동의 시각이에요. | | `null` | 부분 환불이면 `status`는 `paid`로 남고 `refundedAmount`만 늘어요. 누적 환불액이 결제액에 닿으면 `refunded`가 돼요. --- Source: https://docs.payri.kr/developers/rest-api/refunds # Refunds 주문을 직접 환불하거나 구매자가 제출한 환불 요청을 처리해요. #### 엔드포인트 - [POST /v1/orders/{id}/refund](https://docs.payri.kr/developers/rest-api/refunds#주문-환불) - [GET /v1/refund-requests](https://docs.payri.kr/developers/rest-api/refunds#환불-요청-목록) - [GET /v1/refund-requests/{id}](https://docs.payri.kr/developers/rest-api/refunds#환불-요청-단건) - [POST /v1/refund-requests/{id}/approve](https://docs.payri.kr/developers/rest-api/refunds#환불-요청-승인) - [POST /v1/refund-requests/{id}/reject](https://docs.payri.kr/developers/rest-api/refunds#환불-요청-거절) - [POST /v1/refund-requests/{id}/hold](https://docs.payri.kr/developers/rest-api/refunds#환불-요청-보류) ## 주문 환불 **POST https://api.payri.kr/v1/orders/{id}/refund** 전액 또는 부분 환불을 요청해요. Idempotency-Key를 24시간 기억하며 같은 키에 다른 본문을 보내면 409 응답을 돌려줘요. API 키 인증과 `refunds:write` 권한이 필요해요. `Idempotency-Key` 헤더가 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `id (path)` | `string` | 예 | 환불할 주문 식별자예요. | | `ord_123` | | `Idempotency-Key (header)` | `string` | 예 | POST 요청의 중복 실행을 막는 키예요. 24시간 보관하며 본문이 다른 요청에는 같은 키를 쓸 수 없어요. | 1~255자 | | #### 요청 본문 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `amount` | `number` | 아니요 | 부분 환불 금액을 원 단위로 지정해요. | 1 이상 | `5000` | | `reason` | `string` | 아니요 | 환불 사유예요. | | `고객 요청` | #### 요청 예시 ```bash curl -X POST https://api.payri.kr/v1/orders/ord_123/refund \ -H "Authorization: Bearer $PAYRI_API_KEY" \ -H "Idempotency-Key: refund-ord-123-001" \ -H "Content-Type: application/json" \ -d '{ "amount": 5000, "reason": "고객 요청" }' ``` 응답: HTTP 201, [주문 객체](https://docs.payri.kr/developers/rest-api/orders#주문-객체) #### 성공 응답 예시 ```json { "amount": 10000, "billingInterval": null, "buyerEmail": "buyer@example.com", "buyerName": "홍길동", "buyerPhone": "01012345678", "checkoutSessionId": "cs_123", "confirmedAt": null, "coupon": null, "createdAt": "2026-10-15T00:00:00.000Z", "currency": "KRW", "id": "ord_123", "metadata": { "campaign": "fall" }, "method": "card", "mode": "test", "paidAt": "2026-10-15T00:01:00.000Z", "periodEnd": null, "periodStart": null, "planId": null, "productId": "prod_123", "productName": "디지털 가이드", "refundedAmount": 5000, "refundedAt": null, "refunds": [ { "amount": 5000, "at": "2026-10-15T00:02:00.000Z", "currency": "KRW", "reason": "고객 요청" } ], "status": "paid", "subscriptionEvent": null, "subscriptionId": null, "withdrawalNoticeAgreedAt": "2026-10-15T00:00:00.000Z" } ``` #### 오류 코드 | 코드 | HTTP | 의미 | | --- | ---: | --- | | `ORDER_NOT_FOUND` | 404 | 주문이 없거나 키의 상품 범위 밖이에요. | | `REFUND_NOT_PAID` | 409 | 주문 상태가 결제 완료가 아니에요. | | `REFUND_REQUEST_PENDING` | 409 | 활성 환불 요청이 있어 주문을 환불할 수 없어요. | | `REFUND_SETTLED` | 409 | 정산 회차에 포함된 주문은 환불할 수 없어요. | | `REFUND_NONE_LEFT` | 409 | 남은 환불 가능 금액이 없어요. | | `REFUND_AMOUNT_INVALID` | 400 | 환불 금액은 0보다 커야 해요. | | `REFUND_EXCEEDS` | 400 | 환불 금액이 남은 환불 가능액보다 커요. | | `REFUND_IN_PROGRESS` | 409 | 이 주문과 관련한 환불이 처리 중이에요. | | `PG_REFUND_FAILED` | 502 | 결제 대행사가 환불 요청을 처리하지 못했어요. | | `PG_REFUND_AMBIGUOUS` | 502 | 결제 대행사의 환불 결과를 확인하지 못했어요. 실제 환불됐을 수 있으니 상태를 확인한 뒤 다시 처리해 주세요. | 표에 없는 공통 오류(인증·권한, 호출 한도, 입력 검증, 멱등 키, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. **알아둘 점** - 단건 주문을 전액 환불하면 그 주문의 라이선스가 `revoked`가 돼요. - 구독은 가장 최근 결제 주문(최초 또는 갱신)을 전액 환불할 때만 끝나고, 라이선스가 `revoked`가 되며 `subscription.ended`(`endReason: refunded`)가 와요. 지난 회차 주문이나 차액 결제 주문을 환불해도 구독과 라이선스는 그대로예요. - test 주문의 환불 동작은 [test 모드](https://docs.payri.kr/developers/test-mode)에서 확인해요. ## 환불 요청 목록 **GET https://api.payri.kr/v1/refund-requests** API 키의 test 또는 live 환경에 속한 환불 요청을 페이지 단위로 조회해요. API 키 인증과 `refunds:read` 권한이 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `page (query)` | `number` | 아니요 | 조회할 페이지 번호예요. | 1 이상 | `1` | | `limit (query)` | `number` | 아니요 | 페이지마다 조회할 항목 수예요. | 1~100 | `20` | | `status (query)` | `string` | 아니요 | 환불 요청 상태로 결과를 좁혀요. | `requested` · `on_hold` · `processing` · `approved` · `rejected` · `withdrawn` | `requested` | | `orderId (query)` | `string` | 아니요 | 주문 식별자로 환불 요청을 좁혀요. | | `ord_123` | #### 요청 예시 ```bash curl 'https://api.payri.kr/v1/refund-requests?page=1&limit=20&status=requested' \ -H "Authorization: Bearer $PAYRI_API_KEY" ``` 응답: HTTP 200, [환불 요청 객체](https://docs.payri.kr/developers/rest-api/refunds#환불-요청-객체) 목록, 형식 `{ data, total, page, limit }` #### 성공 응답 예시 ```json { "data": [ { "approvedAmount": null, "autoApproveAt": "2026-10-18T00:00:00.000Z", "breakdown": { "orders": [ { "amount": 10000, "currency": "KRW", "orderId": "ord_123" } ] }, "buyerEmail": "buyer@example.com", "buyerReason": "상품을 환불하고 싶어요.", "createdAt": "2026-10-15T00:00:00.000Z", "currency": "KRW", "decidedAt": null, "decidedBy": null, "hold": null, "id": "rfr_123", "kind": "order", "mode": "test", "orderId": "ord_123", "productId": "prod_123", "refundableAmount": 10000, "rejectReason": null, "requestedAmount": 10000, "status": "requested", "statutory": true, "subscriptionId": null, "updatedAt": "2026-10-15T00:00:00.000Z", "warnings": [] } ], "limit": 20, "page": 1, "total": 1 } ``` 표에 없는 공통 오류(인증·권한, 호출 한도, 입력 검증, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. **알아둘 점** - 구매자는 단건 주문 환불을 `https://payri.kr/orders/lookup`(이메일·주문번호)에서 신청하고, 구독은 포털의 즉시 해지로 환불해요. - `requested`, `on_hold`, `processing`은 처리 중인 상태이며, 주문·구독마다 하나만 처리 중일 수 있어요. - 보류 중에는 자동 승인이 멈춰요. ## 환불 요청 단건 **GET https://api.payri.kr/v1/refund-requests/{id}** 환불 요청 식별자로 요청, 결정과 환불 계산 정보를 조회해요. API 키 인증과 `refunds:read` 권한이 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `id (path)` | `string` | 예 | 조회할 환불 요청 식별자예요. | | `rfr_123` | #### 요청 예시 ```bash curl https://api.payri.kr/v1/refund-requests/rfr_123 \ -H "Authorization: Bearer $PAYRI_API_KEY" ``` 응답: HTTP 200, [환불 요청 객체](https://docs.payri.kr/developers/rest-api/refunds#환불-요청-객체) #### 성공 응답 예시 ```json { "approvedAmount": null, "autoApproveAt": "2026-10-18T00:00:00.000Z", "breakdown": { "orders": [ { "amount": 10000, "currency": "KRW", "orderId": "ord_123" } ] }, "buyerEmail": "buyer@example.com", "buyerReason": "상품을 환불하고 싶어요.", "createdAt": "2026-10-15T00:00:00.000Z", "currency": "KRW", "decidedAt": null, "decidedBy": null, "hold": null, "id": "rfr_123", "kind": "order", "mode": "test", "orderId": "ord_123", "productId": "prod_123", "refundableAmount": 10000, "rejectReason": null, "requestedAmount": 10000, "status": "requested", "statutory": true, "subscriptionId": null, "updatedAt": "2026-10-15T00:00:00.000Z", "warnings": [] } ``` #### 오류 코드 | 코드 | HTTP | 의미 | | --- | ---: | --- | | `REFUND_REQUEST_NOT_FOUND` | 404 | 환불 요청이 없거나 키의 상품 범위 밖이에요. | 표에 없는 공통 오류(인증·권한, 호출 한도, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. ## 환불 요청 승인 **POST https://api.payri.kr/v1/refund-requests/{id}/approve** 환불 요청을 승인해요. Idempotency-Key는 24시간 보관해요. API 키 인증과 `refunds:write` 권한이 필요해요. `Idempotency-Key` 헤더가 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `id (path)` | `string` | 예 | 승인할 환불 요청 식별자예요. | | `rfr_123` | | `Idempotency-Key (header)` | `string` | 예 | POST 요청의 중복 실행을 막는 키예요. 24시간 보관하며 본문이 다른 요청에는 같은 키를 쓸 수 없어요. | 1~255자 | | #### 요청 본문 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `acknowledgeMismatch` | `boolean` | 아니요 | 환불 계산 안내를 확인했는지 표시해요. | | `true` | | `amount` | `number` | 아니요 | 승인할 환불 금액을 원 단위로 지정해요. | 1 이상 | `5000` | #### 요청 예시 ```bash curl -X POST https://api.payri.kr/v1/refund-requests/rfr_123/approve \ -H "Authorization: Bearer $PAYRI_API_KEY" \ -H "Idempotency-Key: approve-rfr-123-001" \ -H "Content-Type: application/json" \ -d '{ "acknowledgeMismatch": true, "amount": 10000 }' ``` 응답: HTTP 201, [환불 요청 처리 결과](https://docs.payri.kr/developers/rest-api/refunds#환불-요청-처리-결과) #### 성공 응답 예시 ```json { "refundRequest": { "approvedAmount": null, "autoApproveAt": "2026-10-18T00:00:00.000Z", "breakdown": { "orders": [ { "amount": 10000, "currency": "KRW", "orderId": "ord_123" } ] }, "buyerEmail": "buyer@example.com", "buyerReason": "상품을 환불하고 싶어요.", "createdAt": "2026-10-15T00:00:00.000Z", "currency": "KRW", "decidedAt": null, "decidedBy": null, "hold": null, "id": "rfr_123", "kind": "order", "mode": "test", "orderId": "ord_123", "productId": "prod_123", "refundableAmount": 10000, "rejectReason": null, "requestedAmount": 10000, "status": "requested", "statutory": true, "subscriptionId": null, "updatedAt": "2026-10-15T00:00:00.000Z", "warnings": [] }, "warnings": [] } ``` #### 오류 코드 | 코드 | HTTP | 의미 | | --- | ---: | --- | | `REFUND_REQUEST_NOT_FOUND` | 404 | 환불 요청이 없거나 키의 상품 범위 밖이에요. | | `REFUND_REQUEST_NOT_ACTIONABLE` | 409 | 환불 요청의 현재 상태에서는 승인·거절·보류할 수 없어요. | | `REFUND_STATUTORY_LOCKED` | 409 | 법정 청약철회 기간에는 현재 작업자의 처리가 제한돼요. | | `REFUND_AMOUNT_INVALID` | 400 | 환불 금액은 0보다 커야 해요. | | `REFUND_AMOUNT_EXCEEDS` | 400 | 요청한 환불 금액이 현재 환불 가능액보다 커요. | | `REFUND_AMOUNT_MISMATCH` | 409 | 승인 금액이 요청 금액과 달라요. 금액을 확인하고 차이를 명시적으로 승인해 주세요. | | `ORDER_NOT_FOUND` | 404 | 주문이 없거나 키의 상품 범위 밖이에요. | | `REFUND_NOT_PAID` | 409 | 주문 상태가 결제 완료가 아니에요. | | `REFUND_SETTLED` | 409 | 정산 회차에 포함된 주문은 환불할 수 없어요. | | `REFUND_NONE_LEFT` | 409 | 남은 환불 가능 금액이 없어요. | | `REFUND_EXCEEDS` | 400 | 환불 금액이 남은 환불 가능액보다 커요. | | `REFUND_IN_PROGRESS` | 409 | 이 주문과 관련한 환불이 처리 중이에요. | | `PG_REFUND_FAILED` | 502 | 결제 대행사가 환불 요청을 처리하지 못했어요. | | `PG_REFUND_AMBIGUOUS` | 502 | 결제 대행사의 환불 결과를 확인하지 못했어요. 실제 환불됐을 수 있으니 상태를 확인한 뒤 다시 처리해 주세요. | | `SUBSCRIPTION_REFUND_AMOUNT_INVALID` | 400 | 승인한 구독 환불 금액이 유효하지 않아요. | | `SUBSCRIPTION_REFUND_NONE_LEFT` | 409 | 구독 주문에 남은 환불 가능 금액이 없어요. | | `SUBSCRIPTION_REFUND_SETTLED` | 409 | 즉시 해지 환불 대상 주문이 정산 회차에 포함됐어요. | 표에 없는 공통 오류(인증·권한, 호출 한도, 입력 검증, 멱등 키, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. **알아둘 점** - 요청 금액과 다르게 승인하려면 계산 안내 확인값 `acknowledgeMismatch: true`가 필요해요. - `statutory: true`인 요청은 감액 승인·거절·보류할 수 없어요. - 환불 완료 이벤트는 [웹훅 이벤트](https://docs.payri.kr/developers/webhooks/events)에서 확인해요. ## 환불 요청 거절 **POST https://api.payri.kr/v1/refund-requests/{id}/reject** 환불 요청을 거절하고 사유를 기록해요. Idempotency-Key는 24시간 보관해요. API 키 인증과 `refunds:write` 권한이 필요해요. `Idempotency-Key` 헤더가 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `id (path)` | `string` | 예 | 거절할 환불 요청 식별자예요. | | `rfr_123` | | `Idempotency-Key (header)` | `string` | 예 | POST 요청의 중복 실행을 막는 키예요. 24시간 보관하며 본문이 다른 요청에는 같은 키를 쓸 수 없어요. | 1~255자 | | #### 요청 본문 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `reason` | `string` | 예 | 환불 요청을 거절하는 이유예요. | 최소 1자 | `환불 정책상 처리할 수 없어요.` | #### 요청 예시 ```bash curl -X POST https://api.payri.kr/v1/refund-requests/rfr_123/reject \ -H "Authorization: Bearer $PAYRI_API_KEY" \ -H "Idempotency-Key: reject-rfr-123-001" \ -H "Content-Type: application/json" \ -d '{ "reason": "환불 정책상 처리할 수 없어요." }' ``` 응답: HTTP 201, [환불 요청 처리 결과](https://docs.payri.kr/developers/rest-api/refunds#환불-요청-처리-결과) #### 성공 응답 예시 ```json { "refundRequest": { "approvedAmount": null, "autoApproveAt": "2026-10-18T00:00:00.000Z", "breakdown": { "orders": [ { "amount": 10000, "currency": "KRW", "orderId": "ord_123" } ] }, "buyerEmail": "buyer@example.com", "buyerReason": "상품을 환불하고 싶어요.", "createdAt": "2026-10-15T00:00:00.000Z", "currency": "KRW", "decidedAt": null, "decidedBy": null, "hold": null, "id": "rfr_123", "kind": "order", "mode": "test", "orderId": "ord_123", "productId": "prod_123", "refundableAmount": 10000, "rejectReason": null, "requestedAmount": 10000, "status": "requested", "statutory": true, "subscriptionId": null, "updatedAt": "2026-10-15T00:00:00.000Z", "warnings": [] }, "warnings": [] } ``` #### 오류 코드 | 코드 | HTTP | 의미 | | --- | ---: | --- | | `REFUND_REQUEST_NOT_FOUND` | 404 | 환불 요청이 없거나 키의 상품 범위 밖이에요. | | `REFUND_REQUEST_NOT_ACTIONABLE` | 409 | 환불 요청의 현재 상태에서는 승인·거절·보류할 수 없어요. | | `REFUND_STATUTORY_LOCKED` | 409 | 법정 청약철회 기간에는 현재 작업자의 처리가 제한돼요. | | `REFUND_REASON_REQUIRED` | 400 | 환불 요청을 보류하거나 거절하는 사유를 입력해 주세요. | 표에 없는 공통 오류(인증·권한, 호출 한도, 입력 검증, 멱등 키, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. ## 환불 요청 보류 **POST https://api.payri.kr/v1/refund-requests/{id}/hold** 추가 확인이 필요한 환불 요청을 보류해요. Idempotency-Key는 24시간 보관해요. API 키 인증과 `refunds:write` 권한이 필요해요. `Idempotency-Key` 헤더가 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `id (path)` | `string` | 예 | 보류할 환불 요청 식별자예요. | | `rfr_123` | | `Idempotency-Key (header)` | `string` | 예 | POST 요청의 중복 실행을 막는 키예요. 24시간 보관하며 본문이 다른 요청에는 같은 키를 쓸 수 없어요. | 1~255자 | | #### 요청 본문 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `reason` | `string` | 예 | 보류 사유예요. | 최소 1자 | `추가 확인이 필요해요.` | | `type` | `string` | 예 | 보류 유형이에요. | `in_review` · `disputed` | `in_review` | #### 요청 예시 ```bash curl -X POST https://api.payri.kr/v1/refund-requests/rfr_123/hold \ -H "Authorization: Bearer $PAYRI_API_KEY" \ -H "Idempotency-Key: hold-rfr-123-001" \ -H "Content-Type: application/json" \ -d '{ "reason": "구매 내역을 확인 중이에요.", "type": "in_review" }' ``` 응답: HTTP 201, [환불 요청 처리 결과](https://docs.payri.kr/developers/rest-api/refunds#환불-요청-처리-결과) #### 성공 응답 예시 ```json { "refundRequest": { "approvedAmount": null, "autoApproveAt": "2026-10-18T00:00:00.000Z", "breakdown": { "orders": [ { "amount": 10000, "currency": "KRW", "orderId": "ord_123" } ] }, "buyerEmail": "buyer@example.com", "buyerReason": "상품을 환불하고 싶어요.", "createdAt": "2026-10-15T00:00:00.000Z", "currency": "KRW", "decidedAt": null, "decidedBy": null, "hold": null, "id": "rfr_123", "kind": "order", "mode": "test", "orderId": "ord_123", "productId": "prod_123", "refundableAmount": 10000, "rejectReason": null, "requestedAmount": 10000, "status": "requested", "statutory": true, "subscriptionId": null, "updatedAt": "2026-10-15T00:00:00.000Z", "warnings": [] }, "warnings": [] } ``` #### 오류 코드 | 코드 | HTTP | 의미 | | --- | ---: | --- | | `REFUND_REQUEST_NOT_FOUND` | 404 | 환불 요청이 없거나 키의 상품 범위 밖이에요. | | `REFUND_REQUEST_NOT_ACTIONABLE` | 409 | 환불 요청의 현재 상태에서는 승인·거절·보류할 수 없어요. | | `REFUND_STATUTORY_LOCKED` | 409 | 법정 청약철회 기간에는 현재 작업자의 처리가 제한돼요. | | `REFUND_REASON_REQUIRED` | 400 | 환불 요청을 보류하거나 거절하는 사유를 입력해 주세요. | 표에 없는 공통 오류(인증·권한, 호출 한도, 입력 검증, 멱등 키, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. ## 환불 요청 객체 ### 환불 요청 객체 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `approvedAmount` | `number \| null` | 예 | 승인한 환불 금액을 원 단위로 알려줘요. | | `null` | | `autoApproveAt` | `string \| null` | 예 | 요청 후 72시간 안에 결정되지 않으면 자동 승인되는 시각이에요. | | `2026-10-18T00:00:00.000Z` | | `breakdown` | `object` | 예 | 주문별 환불 금액 내역이에요. | | | | `breakdown.orders` | `array of object` | 예 | 환불 대상 주문별 금액이에요. | | | | `breakdown.orders[].amount` | `number` | 예 | 대상 주문의 환불 금액이에요. | | `5000` | | `breakdown.orders[].currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `breakdown.orders[].orderId` | `string` | 예 | 환불 대상 주문 식별자예요. | | `ord_123` | | `buyerEmail` | `string` | 예 | 구매자 이메일 주소예요. | | `buyer@example.com` | | `buyerReason` | `string \| null` | 예 | 구매자가 남긴 환불 사유예요. | | `null` | | `createdAt` | `string` | 예 | 환불 요청이 생성된 시각이에요. | | `2026-10-15T00:00:00.000Z` | | `currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `decidedAt` | `string \| null` | 예 | 요청이 결정된 시각이에요. | | `null` | | `decidedBy` | `string \| null` | 예 | 요청을 결정한 사용자 식별자예요. | | `null` | | `hold` | `object \| null` | 예 | 보류 처리 정보예요. | | | | `hold.heldAt` | `string` | 예 | 보류 처리 시각이에요. | | `2026-10-15T00:00:00.000Z` | | `hold.reason` | `string \| null` | 예 | 보류 사유예요. | | `추가 확인 중이에요.` | | `hold.type` | `string` | 예 | 보류 유형이에요. | `in_review` · `disputed` | `in_review` | | `id` | `string` | 예 | 환불 요청 식별자예요. | | `rfr_123` | | `kind` | `string` | 예 | 환불 요청 유형이에요. | `order` · `subscription_cancel` | `order` | | `mode` | `string` | 예 | test 또는 live 환불 환경을 알려줘요. | `live` · `test` | `test` | | `orderId` | `string` | 예 | 환불 요청의 주문 식별자예요. | | `ord_123` | | `productId` | `string` | 예 | 환불 요청의 상품 식별자예요. | | `prod_123` | | `refundableAmount` | `number` | 아니요 | 현재 환불 가능한 금액을 원 단위로 알려줘요. | | `10000` | | `rejectReason` | `string \| null` | 예 | 거절 사유예요. | | `null` | | `requestedAmount` | `number` | 예 | 요청한 환불 금액을 원 단위로 알려줘요. | | `10000` | | `status` | `string` | 예 | 환불 요청 상태예요. | `requested` · `on_hold` · `processing` · `approved` · `rejected` · `withdrawn` | `requested` | | `statutory` | `boolean` | 예 | 법정 청약 철회 요청인지 알려줘요. | | `false` | | `subscriptionId` | `string \| null` | 예 | 환불 요청의 구독 식별자예요. | | `null` | | `updatedAt` | `string` | 예 | 환불 요청이 수정된 시각이에요. | | `2026-10-15T00:00:00.000Z` | | `warnings` | `array of object` | 아니요 | 환불 처리 전에 확인할 안내 목록이에요. | | | | `warnings[].code` | `string` | 예 | 환불 안내를 구분하는 코드예요. | `STATUTORY_LOCKED` · `SUBSCRIPTION_CANCEL_REFUND_RIGHT` · `POLICY_DECISION` | `POLICY_DECISION` | | `warnings[].message` | `string` | 예 | 환불 처리 시 참고할 안내예요. | | `환불 가능 기간이 지났어요.` | ## 환불 요청 처리 결과 ### 환불 요청 처리 결과 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `refundRequest` | `object \| null` | 예 | 환불 요청 처리 후의 정보예요. | | | | `refundRequest.approvedAmount` | `number \| null` | 예 | 승인한 환불 금액을 원 단위로 알려줘요. | | `null` | | `refundRequest.autoApproveAt` | `string \| null` | 예 | 요청 후 72시간 안에 결정되지 않으면 자동 승인되는 시각이에요. | | `2026-10-18T00:00:00.000Z` | | `refundRequest.breakdown` | `object` | 예 | 주문별 환불 금액 내역이에요. | | | | `refundRequest.breakdown.orders` | `array of object` | 예 | 환불 대상 주문별 금액이에요. | | | | `refundRequest.breakdown.orders[].amount` | `number` | 예 | 대상 주문의 환불 금액이에요. | | `5000` | | `refundRequest.breakdown.orders[].currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `refundRequest.breakdown.orders[].orderId` | `string` | 예 | 환불 대상 주문 식별자예요. | | `ord_123` | | `refundRequest.buyerEmail` | `string` | 예 | 구매자 이메일 주소예요. | | `buyer@example.com` | | `refundRequest.buyerReason` | `string \| null` | 예 | 구매자가 남긴 환불 사유예요. | | `null` | | `refundRequest.createdAt` | `string` | 예 | 환불 요청이 생성된 시각이에요. | | `2026-10-15T00:00:00.000Z` | | `refundRequest.currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `refundRequest.decidedAt` | `string \| null` | 예 | 요청이 결정된 시각이에요. | | `null` | | `refundRequest.decidedBy` | `string \| null` | 예 | 요청을 결정한 사용자 식별자예요. | | `null` | | `refundRequest.hold` | `object \| null` | 예 | 보류 처리 정보예요. | | | | `refundRequest.hold.heldAt` | `string` | 예 | 보류 처리 시각이에요. | | `2026-10-15T00:00:00.000Z` | | `refundRequest.hold.reason` | `string \| null` | 예 | 보류 사유예요. | | `추가 확인 중이에요.` | | `refundRequest.hold.type` | `string` | 예 | 보류 유형이에요. | `in_review` · `disputed` | `in_review` | | `refundRequest.id` | `string` | 예 | 환불 요청 식별자예요. | | `rfr_123` | | `refundRequest.kind` | `string` | 예 | 환불 요청 유형이에요. | `order` · `subscription_cancel` | `order` | | `refundRequest.mode` | `string` | 예 | test 또는 live 환불 환경을 알려줘요. | `live` · `test` | `test` | | `refundRequest.orderId` | `string` | 예 | 환불 요청의 주문 식별자예요. | | `ord_123` | | `refundRequest.productId` | `string` | 예 | 환불 요청의 상품 식별자예요. | | `prod_123` | | `refundRequest.refundableAmount` | `number` | 아니요 | 현재 환불 가능한 금액을 원 단위로 알려줘요. | | `10000` | | `refundRequest.rejectReason` | `string \| null` | 예 | 거절 사유예요. | | `null` | | `refundRequest.requestedAmount` | `number` | 예 | 요청한 환불 금액을 원 단위로 알려줘요. | | `10000` | | `refundRequest.status` | `string` | 예 | 환불 요청 상태예요. | `requested` · `on_hold` · `processing` · `approved` · `rejected` · `withdrawn` | `requested` | | `refundRequest.statutory` | `boolean` | 예 | 법정 청약 철회 요청인지 알려줘요. | | `false` | | `refundRequest.subscriptionId` | `string \| null` | 예 | 환불 요청의 구독 식별자예요. | | `null` | | `refundRequest.updatedAt` | `string` | 예 | 환불 요청이 수정된 시각이에요. | | `2026-10-15T00:00:00.000Z` | | `refundRequest.warnings` | `array of object` | 아니요 | 환불 처리 전에 확인할 안내 목록이에요. | | | | `refundRequest.warnings[].code` | `string` | 예 | 환불 안내를 구분하는 코드예요. | `STATUTORY_LOCKED` · `SUBSCRIPTION_CANCEL_REFUND_RIGHT` · `POLICY_DECISION` | `POLICY_DECISION` | | `refundRequest.warnings[].message` | `string` | 예 | 환불 처리 시 참고할 안내예요. | | `환불 가능 기간이 지났어요.` | | `warnings` | `array of object` | 예 | 처리 결과와 함께 전달하는 안내 목록이에요. | | | | `warnings[].code` | `string` | 예 | 환불 안내를 구분하는 코드예요. | `STATUTORY_LOCKED` · `SUBSCRIPTION_CANCEL_REFUND_RIGHT` · `POLICY_DECISION` | `POLICY_DECISION` | | `warnings[].message` | `string` | 예 | 환불 처리 시 참고할 안내예요. | | `환불 가능 기간이 지났어요.` | --- Source: https://docs.payri.kr/developers/rest-api/subscriptions # Subscriptions 구독을 조회하고 해지 미리보기와 해지를 요청해요. #### 엔드포인트 - [GET /v1/subscriptions](https://docs.payri.kr/developers/rest-api/subscriptions#구독-목록) - [GET /v1/subscriptions/{id}](https://docs.payri.kr/developers/rest-api/subscriptions#구독-단건) - [GET /v1/subscriptions/{id}/cancel-preview](https://docs.payri.kr/developers/rest-api/subscriptions#해지-미리보기) - [POST /v1/subscriptions/{id}/cancel](https://docs.payri.kr/developers/rest-api/subscriptions#구독-해지) ## 구독 목록 **GET https://api.payri.kr/v1/subscriptions** API 키의 test 또는 live 환경과 상품 범위에 속한 구독을 페이지 단위로 조회해요. API 키 인증과 `subscriptions:read` 권한이 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `page (query)` | `number` | 아니요 | 조회할 페이지 번호예요. | 1 이상 | `1` | | `limit (query)` | `number` | 아니요 | 페이지마다 조회할 항목 수예요. | 1~100 | `20` | | `buyerEmail (query)` | `string` | 아니요 | 구매자 이메일로 구독을 찾아요. | 이메일 | `buyer@example.com` | | `status (query)` | `string` | 아니요 | 구독의 공개 상태로 결과를 좁혀요. 결제가 밀린 채 해지를 예약한 구독도 past_due 로 조회돼요. | `active` · `past_due` · `ended` | `active` | | `productId (query)` | `string` | 아니요 | 상품 식별자로 구독을 좁혀요. | | `prod_123` | #### 요청 예시 ```bash curl 'https://api.payri.kr/v1/subscriptions?page=1&limit=20&status=active' \ -H "Authorization: Bearer $PAYRI_API_KEY" ``` 응답: HTTP 200, [구독 객체](https://docs.payri.kr/developers/rest-api/subscriptions#구독-객체) 목록, 형식 `{ data, total, page, limit }` #### 성공 응답 예시 ```json { "data": [ { "amount": 9900, "billingInterval": "monthly", "buyerEmail": "buyer@example.com", "buyerName": "홍길동", "cancelAtPeriodEnd": false, "currency": "KRW", "currentPeriodEnd": "2026-11-01T00:00:00.000Z", "currentPeriodStart": "2026-10-01T00:00:00.000Z", "endReason": null, "endedAt": null, "history": [], "id": "sub_123", "mode": "test", "pastDueSince": null, "pendingPriceChange": null, "planId": "plan_basic", "productId": "prod_123", "productName": "멤버십", "refundEligibleUntil": "2026-10-08T00:00:00.000Z", "refundTerms": { "dayRounding": "ceil", "initialFullRefundDays": 7, "listAmounts": null, "penalty": null, "policyType": "within_7days", "renewalFullRefundDays": 7, "requireApproval": false, "restrictRepeatFullRefund": false, "yearlyDiscountClawback": false }, "scheduledChange": null, "startedAt": "2026-10-01T00:00:00.000Z", "status": "active", "testRenewalOutcome": "succeed" } ], "limit": 20, "page": 1, "total": 1 } ``` 표에 없는 공통 오류(인증·권한, 호출 한도, 입력 검증, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. ## 구독 단건 **GET https://api.payri.kr/v1/subscriptions/{id}** 구독의 공개 상태, 청구 기간과 예약 변경 정보를 조회해요. API 키 인증과 `subscriptions:read` 권한이 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `id (path)` | `string` | 예 | 조회할 구독 식별자예요. | | `sub_123` | #### 요청 예시 ```bash curl https://api.payri.kr/v1/subscriptions/sub_123 \ -H "Authorization: Bearer $PAYRI_API_KEY" ``` 응답: HTTP 200, [구독 객체](https://docs.payri.kr/developers/rest-api/subscriptions#구독-객체) #### 성공 응답 예시 ```json { "amount": 9900, "billingInterval": "monthly", "buyerEmail": "buyer@example.com", "buyerName": "홍길동", "cancelAtPeriodEnd": false, "currency": "KRW", "currentPeriodEnd": "2026-11-01T00:00:00.000Z", "currentPeriodStart": "2026-10-01T00:00:00.000Z", "endReason": null, "endedAt": null, "history": [], "id": "sub_123", "mode": "test", "pastDueSince": null, "pendingPriceChange": null, "planId": "plan_basic", "productId": "prod_123", "productName": "멤버십", "refundEligibleUntil": "2026-10-08T00:00:00.000Z", "refundTerms": { "dayRounding": "ceil", "initialFullRefundDays": 7, "listAmounts": null, "penalty": null, "policyType": "within_7days", "renewalFullRefundDays": 7, "requireApproval": false, "restrictRepeatFullRefund": false, "yearlyDiscountClawback": false }, "scheduledChange": null, "startedAt": "2026-10-01T00:00:00.000Z", "status": "active", "testRenewalOutcome": "succeed" } ``` #### 오류 코드 | 코드 | HTTP | 의미 | | --- | ---: | --- | | `SUBSCRIPTION_NOT_FOUND` | 404 | 구독이 없거나 키의 상품 범위 밖이에요. | 표에 없는 공통 오류(인증·권한, 호출 한도, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. **알아둘 점** - 기간말 해지를 예약해도 `status`는 바뀌지 않고(`active` 또는 `past_due`) `cancelAtPeriodEnd`가 `true`가 돼요. ## 해지 미리보기 **GET https://api.payri.kr/v1/subscriptions/{id}/cancel-preview** 기간 종료 해지와 즉시 해지의 적용 시점과 환불 금액을 비교해요. API 키 인증과 `subscriptions:read` 권한이 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `id (path)` | `string` | 예 | 미리보기할 구독 식별자예요. | | `sub_123` | #### 요청 예시 ```bash curl https://api.payri.kr/v1/subscriptions/sub_123/cancel-preview \ -H "Authorization: Bearer $PAYRI_API_KEY" ``` #### 성공 응답 필드 (HTTP 200) | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `immediate` | `object` | 예 | 즉시 해지 미리보기예요. | | | | `immediate.blockedReason` | `object \| null` | 예 | 즉시 해지가 불가능한 이유예요. | | | | `immediate.blockedReason.code` | `string` | 예 | 즉시 해지가 차단된 이유 코드예요. | `SUBSCRIPTION_REFUND_SETTLED` · `REFUND_REQUEST_PENDING` · `ALREADY_ENDED` | `REFUND_REQUEST_PENDING` | | `immediate.blockedReason.message` | `string` | 예 | 즉시 해지가 차단된 이유예요. | | `처리 중인 환불 요청이 있어요.` | | `immediate.breakdown` | `array of object` | 예 | 주문별 환불 금액 내역이에요. | | | | `immediate.breakdown[].amount` | `number` | 예 | 해당 주문에서 환불할 금액이에요. | | `5000` | | `immediate.breakdown[].currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `immediate.breakdown[].orderId` | `string` | 예 | 환불 계산 대상 주문 식별자예요. | | `ord_123` | | `immediate.clawback` | `number` | 예 | 연간 할인 환수 금액이에요. | | `0` | | `immediate.clawbackCurrency` | `string` | 예 | 환수 금액 통화예요. | `KRW` | `KRW` | | `immediate.currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `immediate.deduction` | `number` | 예 | 환불액에서 차감할 금액이에요. | | `0` | | `immediate.deductionCap` | `number` | 예 | 차감할 수 있는 최대 금액이에요. | | `0` | | `immediate.deductionCapCurrency` | `string` | 예 | 차감 한도 통화예요. | `KRW` | `KRW` | | `immediate.deductionCurrency` | `string` | 예 | 차감 금액 통화예요. | `KRW` | `KRW` | | `immediate.fullRefund` | `boolean` | 예 | 전액 환불인지 알려줘요. | | `false` | | `immediate.penalty` | `number` | 예 | 위약금 금액이에요. | | `0` | | `immediate.penaltyCurrency` | `string` | 예 | 위약금 통화예요. | `KRW` | `KRW` | | `immediate.reason` | `string` | 예 | 환불 계산 사유예요. | `initial_full` · `statutory_full` · `renewal_full` · `prorated` · `past_due` · `nothing_refundable` | `prorated` | | `immediate.refundAmount` | `number` | 예 | 즉시 해지 시 환불 예정 금액이에요. | | `5000` | | `immediate.requiresApproval` | `boolean` | 예 | 환불 요청의 판매자 승인이 필요한지 알려줘요. | | `false` | | `immediate.statutory` | `boolean` | 예 | 법정 환불 기준 적용 여부예요. | | `false` | | `immediate.totalDays` | `number` | 예 | 환불 계산에 사용하는 전체 일수예요. | | `30` | | `immediate.usedDays` | `number` | 예 | 구독 시작 후 사용한 일수예요. | | `5` | | `mode` | `string` | 예 | test 또는 live 구독 환경을 알려줘요. | `live` · `test` | `live` | | `pendingRefundRequest` | `object \| null` | 예 | 처리 중인 환불 요청 정보예요. | | | | `pendingRefundRequest.approvedAmount` | `number \| null` | 예 | 승인한 환불 금액을 원 단위로 알려줘요. | | `null` | | `pendingRefundRequest.autoApproveAt` | `string \| null` | 예 | 요청 후 72시간 안에 결정되지 않으면 자동 승인되는 시각이에요. | | `2026-10-18T00:00:00.000Z` | | `pendingRefundRequest.breakdown` | `object` | 예 | 주문별 환불 금액 내역이에요. | | | | `pendingRefundRequest.breakdown.orders` | `array of object` | 예 | 환불 대상 주문별 금액이에요. | | | | `pendingRefundRequest.breakdown.orders[].amount` | `number` | 예 | 대상 주문의 환불 금액이에요. | | `5000` | | `pendingRefundRequest.breakdown.orders[].currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `pendingRefundRequest.breakdown.orders[].orderId` | `string` | 예 | 환불 대상 주문 식별자예요. | | `ord_123` | | `pendingRefundRequest.buyerEmail` | `string` | 예 | 구매자 이메일 주소예요. | | `buyer@example.com` | | `pendingRefundRequest.buyerReason` | `string \| null` | 예 | 구매자가 남긴 환불 사유예요. | | `null` | | `pendingRefundRequest.createdAt` | `string` | 예 | 환불 요청이 생성된 시각이에요. | | `2026-10-15T00:00:00.000Z` | | `pendingRefundRequest.currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `pendingRefundRequest.decidedAt` | `string \| null` | 예 | 요청이 결정된 시각이에요. | | `null` | | `pendingRefundRequest.decidedBy` | `string \| null` | 예 | 요청을 결정한 사용자 식별자예요. | | `null` | | `pendingRefundRequest.hold` | `object \| null` | 예 | 보류 처리 정보예요. | | | | `pendingRefundRequest.hold.heldAt` | `string` | 예 | 보류 처리 시각이에요. | | `2026-10-15T00:00:00.000Z` | | `pendingRefundRequest.hold.reason` | `string \| null` | 예 | 보류 사유예요. | | `추가 확인 중이에요.` | | `pendingRefundRequest.hold.type` | `string` | 예 | 보류 유형이에요. | `in_review` · `disputed` | `in_review` | | `pendingRefundRequest.id` | `string` | 예 | 환불 요청 식별자예요. | | `rfr_123` | | `pendingRefundRequest.kind` | `string` | 예 | 환불 요청 유형이에요. | `order` · `subscription_cancel` | `order` | | `pendingRefundRequest.mode` | `string` | 예 | test 또는 live 환불 환경을 알려줘요. | `live` · `test` | `test` | | `pendingRefundRequest.orderId` | `string` | 예 | 환불 요청의 주문 식별자예요. | | `ord_123` | | `pendingRefundRequest.productId` | `string` | 예 | 환불 요청의 상품 식별자예요. | | `prod_123` | | `pendingRefundRequest.refundableAmount` | `number` | 아니요 | 현재 환불 가능한 금액을 원 단위로 알려줘요. | | `10000` | | `pendingRefundRequest.rejectReason` | `string \| null` | 예 | 거절 사유예요. | | `null` | | `pendingRefundRequest.requestedAmount` | `number` | 예 | 요청한 환불 금액을 원 단위로 알려줘요. | | `10000` | | `pendingRefundRequest.status` | `string` | 예 | 환불 요청 상태예요. | `requested` · `on_hold` · `processing` · `approved` · `rejected` · `withdrawn` | `requested` | | `pendingRefundRequest.statutory` | `boolean` | 예 | 법정 청약 철회 요청인지 알려줘요. | | `false` | | `pendingRefundRequest.subscriptionId` | `string \| null` | 예 | 환불 요청의 구독 식별자예요. | | `null` | | `pendingRefundRequest.updatedAt` | `string` | 예 | 환불 요청이 수정된 시각이에요. | | `2026-10-15T00:00:00.000Z` | | `pendingRefundRequest.warnings` | `array of object` | 아니요 | 환불 처리 전에 확인할 안내 목록이에요. | | | | `pendingRefundRequest.warnings[].code` | `string` | 예 | 환불 안내를 구분하는 코드예요. | `STATUTORY_LOCKED` · `SUBSCRIPTION_CANCEL_REFUND_RIGHT` · `POLICY_DECISION` | `POLICY_DECISION` | | `pendingRefundRequest.warnings[].message` | `string` | 예 | 환불 처리 시 참고할 안내예요. | | `환불 가능 기간이 지났어요.` | | `periodEnd` | `object` | 예 | 기간 종료 해지 미리보기예요. | | | | `periodEnd.effectiveAt` | `string` | 예 | 기간 종료 해지 효력이 발생할 시각이에요. | | `2026-11-01T00:00:00.000Z` | #### 성공 응답 예시 ```json { "immediate": { "blockedReason": null, "breakdown": [ { "amount": 5000, "currency": "KRW", "orderId": "ord_123" } ], "clawback": 0, "clawbackCurrency": "KRW", "currency": "KRW", "deduction": 0, "deductionCap": 0, "deductionCapCurrency": "KRW", "deductionCurrency": "KRW", "fullRefund": false, "penalty": 0, "penaltyCurrency": "KRW", "reason": "prorated", "refundAmount": 5000, "requiresApproval": false, "statutory": false, "totalDays": 30, "usedDays": 5 }, "mode": "test", "pendingRefundRequest": null, "periodEnd": { "effectiveAt": "2026-11-01T00:00:00.000Z" } } ``` #### 오류 코드 | 코드 | HTTP | 의미 | | --- | ---: | --- | | `SUBSCRIPTION_NOT_FOUND` | 404 | 구독이 없거나 키의 상품 범위 밖이에요. | 표에 없는 공통 오류(인증·권한, 호출 한도, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. ## 구독 해지 **POST https://api.payri.kr/v1/subscriptions/{id}/cancel** 기간 종료 시점 또는 즉시 해지해요. timing은 필수예요. Idempotency-Key는 24시간 보관해요. API 키 인증과 `subscriptions:write` 권한이 필요해요. `Idempotency-Key` 헤더가 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `id (path)` | `string` | 예 | 해지할 구독 식별자예요. | | `sub_123` | | `Idempotency-Key (header)` | `string` | 예 | POST 요청의 중복 실행을 막는 키예요. 24시간 보관하며 본문이 다른 요청에는 같은 키를 쓸 수 없어요. | 1~255자 | | #### 요청 본문 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `reason` | `string` | 아니요 | 해지 사유예요. | | `서비스 이용을 중단해요.` | | `timing` | `string` | 예 | 기간 종료 시점 또는 즉시 해지 시점을 지정해요. | `period_end` · `immediate` | `period_end` | #### 요청 예시 ```bash curl -X POST https://api.payri.kr/v1/subscriptions/sub_123/cancel \ -H "Authorization: Bearer $PAYRI_API_KEY" \ -H "Idempotency-Key: cancel-sub-123-001" \ -H "Content-Type: application/json" \ -d '{ "timing": "period_end", "reason": "이용 종료" }' ``` #### 성공 응답 필드 (HTTP 201) | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `refundAmount` | `number` | 예 | 즉시 환불한 금액이에요. | | `0` | | `refundOrderId` | `string \| null` | 예 | 즉시 환불한 주문 식별자예요. | | `null` | | `refundedOrderIds` | `array of string` | 예 | 해지 처리 중 환불한 주문 식별자 목록이에요. | | `["ord_123"]` | | `subscription` | `object` | 예 | 해지 후 구독 정보예요. | | | | `subscription.amount` | `number` | 예 | 갱신할 때 청구하는 주기당 금액의 상한을 원 단위 정수로 알려줘요. 상품 가격이 내려가면 다음 갱신부터 낮아지고, 인상은 고지 후 구매자가 동의해야 적용돼요. | | `9900` | | `subscription.billingInterval` | `string` | 예 | 구독 결제 주기예요. | `monthly` · `yearly` | `monthly` | | `subscription.buyerEmail` | `string` | 예 | 구매자 이메일 주소예요. | | `buyer@example.com` | | `subscription.buyerName` | `string` | 예 | 구매자 이름이에요. | | `홍길동` | | `subscription.cancelAtPeriodEnd` | `boolean` | 예 | 다음 기간 종료 시 해지하도록 예약했는지 알려줘요. | | `false` | | `subscription.currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `subscription.currentPeriodEnd` | `string` | 예 | 현재 결제 기간 종료 시각이에요. | | `2026-11-01T00:00:00.000Z` | | `subscription.currentPeriodStart` | `string` | 예 | 현재 결제 기간 시작 시각이에요. | | `2026-10-01T00:00:00.000Z` | | `subscription.endReason` | `string \| null` | 예 | 구독 종료 사유예요. | `canceled` · `refunded` · `payment_failed` · `product_unavailable` · `plan_unavailable` · `pg_canceled` · `price_increase_declined` · `null` | `null` | | `subscription.endedAt` | `string \| null` | 예 | 구독이 종료된 시각이에요. | | `null` | | `subscription.history` | `array of object \| null` | 예 | 구독 변경 이력이에요. | | | | `subscription.history[].at` | `string` | 예 | 변경이 기록된 시각이에요. | | `2026-10-15T00:00:00.000Z` | | `subscription.history[].effectiveAt` | `string \| null` | 예 | 변경 효력이 발생하는 시각이에요. | | `null` | | `subscription.history[].extensionDays` | `number \| null` | 예 | 구독 기간 연장 일수예요. | | `null` | | `subscription.history[].from` | `object \| null` | 예 | 변경 전 플랜과 주기예요. | | | | `subscription.history[].from.billingInterval` | `string` | 예 | 변경 전후 청구 주기예요. | `monthly` · `yearly` | `monthly` | | `subscription.history[].from.planId` | `string` | 예 | 변경 전후 구독 플랜 식별자예요. | | `plan_basic` | | `subscription.history[].note` | `string \| null` | 예 | 이전 변경 기록의 메모예요. | | `null` | | `subscription.history[].orderId` | `string \| null` | 예 | 변경과 연결된 주문 식별자예요. | | `null` | | `subscription.history[].prorationAmount` | `number \| null` | 예 | 차액 결제 금액, 또는 갱신·가격 변경 이력의 금액을 원 단위로 알려줘요. | | `null` | | `subscription.history[].to` | `object \| null` | 예 | 변경 후 플랜과 주기예요. | | | | `subscription.history[].to.billingInterval` | `string` | 예 | 변경 전후 청구 주기예요. | `monthly` · `yearly` | `monthly` | | `subscription.history[].to.planId` | `string` | 예 | 변경 전후 구독 플랜 식별자예요. | | `plan_basic` | | `subscription.history[].type` | `string \| null` | 예 | 내부 변경 기록 유형이에요. | `created` · `renewed` · `upgraded` · `downgrade_scheduled` · `downgrade_applied` · `interval_changed` · `cancel_scheduled` · `cancel_undone` · `ended` · `refunded` · `canceled_with_refund` · `comp_upgraded` · `period_extended` · `payment_failed` · `price_change_noticed` · `price_change_consented` · `null` | `renewed` | | `subscription.id` | `string` | 예 | 구독 식별자예요. | | `sub_123` | | `subscription.mode` | `string` | 예 | test 또는 live 구독 환경을 알려줘요. | `live` · `test` | `test` | | `subscription.pastDueSince` | `string \| null` | 예 | 결제 실패 상태가 시작된 시각이에요. | | `null` | | `subscription.pendingPriceChange` | `object \| null` | 예 | 가격 인상 고지와 구매자 동의 상태예요. | | | | `subscription.pendingPriceChange.amount` | `number` | 예 | 고지된 새 구독 금액을 원 단위로 알려줘요. | | `12000` | | `subscription.pendingPriceChange.consentedAt` | `string \| null` | 예 | 구매자가 새 가격에 동의한 시각이에요. | | `null` | | `subscription.pendingPriceChange.currency` | `string` | 예 | 고지된 금액의 통화예요. | `KRW` | `KRW` | | `subscription.pendingPriceChange.effectiveFrom` | `string` | 예 | 새 가격이 적용될 갱신일이에요. | | `2026-11-01T00:00:00.000Z` | | `subscription.pendingPriceChange.noticedAt` | `string` | 예 | 가격 인상을 고지한 시각이에요. | | `2026-10-01T00:00:00.000Z` | | `subscription.planId` | `string` | 예 | 구독 플랜 식별자예요. | | `plan_basic` | | `subscription.productId` | `string` | 예 | 상품 식별자예요. | | `prod_123` | | `subscription.productName` | `string` | 예 | 상품 이름이에요. | | `멤버십` | | `subscription.refundEligibleUntil` | `string` | 예 | 최초 결제 환불 가능 기한이에요. | | `2026-10-08T00:00:00.000Z` | | `subscription.refundTerms` | `object` | 예 | 구독 환불 조건의 스냅샷이에요. | | | | `subscription.refundTerms.dayRounding` | `string \| null` | 예 | 일수 계산 방식이에요. | `ceil` · `floor` · `null` | `null` | | `subscription.refundTerms.initialFullRefundDays` | `number \| null` | 예 | 최초 결제 전액 환불 가능 일수예요. | | `null` | | `subscription.refundTerms.listAmounts` | `object \| null` | 예 | 환불 계산에 사용하는 기준 금액이에요. | | | | `subscription.refundTerms.listAmounts.currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `subscription.refundTerms.listAmounts.monthly` | `number \| null` | 예 | 월간 기준 금액이에요. | | `null` | | `subscription.refundTerms.listAmounts.yearly` | `number \| null` | 예 | 연간 기준 금액이에요. | | `null` | | `subscription.refundTerms.penalty` | `object \| null` | 예 | 환불 위약금 조건이에요. | | | | `subscription.refundTerms.penalty.currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `subscription.refundTerms.penalty.fixedAmount` | `number \| null` | 예 | 고정 위약금을 원 단위로 알려줘요. | | `null` | | `subscription.refundTerms.penalty.rateBps` | `number \| null` | 예 | 위약금 비율을 1만분율로 알려줘요. | | `null` | | `subscription.refundTerms.policyType` | `string \| null` | 예 | 구독 환불 정책 유형이에요. | `no_refund` · `before_delivery` · `within_7days` · `custom` · `null` | `null` | | `subscription.refundTerms.renewalFullRefundDays` | `number \| null` | 예 | 갱신 결제 전액 환불 가능 일수예요. | | `null` | | `subscription.refundTerms.requireApproval` | `boolean \| null` | 예 | 환불 요청에 승인이 필요한지 알려줘요. | | `false` | | `subscription.refundTerms.restrictRepeatFullRefund` | `boolean \| null` | 예 | 반복 전액 환불 제한 여부예요. | | `null` | | `subscription.refundTerms.yearlyDiscountClawback` | `boolean \| null` | 예 | 연간 할인 환수 여부예요. | | `null` | | `subscription.scheduledChange` | `object \| null` | 예 | 예약된 구독 변경 정보예요. | | | | `subscription.scheduledChange.billingInterval` | `string \| null` | 예 | 예약된 구독 청구 주기예요. | `monthly` · `yearly` · `null` | `null` | | `subscription.scheduledChange.effectiveAt` | `string` | 예 | 예약 변경 효력이 발생하는 시각이에요. | | `2026-11-01T00:00:00.000Z` | | `subscription.scheduledChange.planId` | `string \| null` | 예 | 예약된 구독 플랜 식별자예요. | | `null` | | `subscription.scheduledChange.type` | `string` | 예 | 예약 변경 유형이에요. | `downgrade` · `interval_change` · `cancel` | `cancel` | | `subscription.startedAt` | `string` | 예 | 구독이 시작된 시각이에요. | | `2026-10-01T00:00:00.000Z` | | `subscription.status` | `string` | 예 | 구독의 공개 상태예요. past_due 는 결제가 밀린 상태예요. 결제가 밀린 채 해지를 예약해도 past_due 이고 cancelAtPeriodEnd 가 true 예요. | `active` · `past_due` · `ended` | `active` | | `subscription.testRenewalOutcome` | `string \| null` | 예 | test 갱신 결과 설정이에요. | `succeed` · `fail` · `null` | `succeed` | | `timing` | `string` | 예 | 해지 시점이에요. | `period_end` · `immediate` | `period_end` | #### 성공 응답 예시 ```json { "currency": "KRW", "refundAmount": 0, "refundOrderId": null, "refundedOrderIds": [], "subscription": { "amount": 9900, "billingInterval": "monthly", "buyerEmail": "buyer@example.com", "buyerName": "홍길동", "cancelAtPeriodEnd": true, "currency": "KRW", "currentPeriodEnd": "2026-11-01T00:00:00.000Z", "currentPeriodStart": "2026-10-01T00:00:00.000Z", "endReason": null, "endedAt": null, "history": [], "id": "sub_123", "mode": "test", "pastDueSince": null, "pendingPriceChange": null, "planId": "plan_basic", "productId": "prod_123", "productName": "멤버십", "refundEligibleUntil": "2026-10-08T00:00:00.000Z", "refundTerms": { "dayRounding": "ceil", "initialFullRefundDays": 7, "listAmounts": null, "penalty": null, "policyType": "within_7days", "renewalFullRefundDays": 7, "requireApproval": false, "restrictRepeatFullRefund": false, "yearlyDiscountClawback": false }, "scheduledChange": { "billingInterval": null, "effectiveAt": "2026-11-01T00:00:00.000Z", "planId": null, "type": "cancel" }, "startedAt": "2026-10-01T00:00:00.000Z", "status": "active", "testRenewalOutcome": "succeed" }, "timing": "period_end" } ``` #### 오류 코드 | 코드 | HTTP | 의미 | | --- | ---: | --- | | `SUBSCRIPTION_NOT_FOUND` | 404 | 구독이 없거나 키의 상품 범위 밖이에요. | | `REFUND_REQUEST_PENDING` | 409 | 활성 환불 요청이 있어 주문을 환불할 수 없어요. | | `REFUND_IN_PROGRESS` | 409 | 이 주문과 관련한 환불이 처리 중이에요. | | `SUBSCRIPTION_REFUND_SETTLED` | 409 | 즉시 해지 환불 대상 주문이 정산 회차에 포함됐어요. | | `SUBSCRIPTION_REFUND_NONE_LEFT` | 409 | 구독 주문에 남은 환불 가능 금액이 없어요. | | `ORDER_NOT_FOUND` | 404 | 주문이 없거나 키의 상품 범위 밖이에요. | | `PG_REFUND_FAILED` | 502 | 결제 대행사가 환불 요청을 처리하지 못했어요. | | `PG_REFUND_AMBIGUOUS` | 502 | 결제 대행사의 환불 결과를 확인하지 못했어요. 실제 환불됐을 수 있으니 상태를 확인한 뒤 다시 처리해 주세요. | | `ALREADY_ENDED` | 409 | 이미 종료된 구독이에요. | | `ALREADY_CANCELED` | 409 | 이미 기간말 해지가 예약돼 있어요. | 표에 없는 공통 오류(인증·권한, 호출 한도, 입력 검증, 멱등 키, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. **알아둘 점** - `period_end`는 기간말 해지 예약, `immediate`는 즉시 종료예요. - 즉시 해지 환불액은 환불 정책으로 계산되고 금액은 지정할 수 없어요. 환불액이 있으면 환불된 주문마다 `order.refunded`가 온 뒤 `subscription.ended`가 와요. - `immediate`로 끝난 구독의 라이선스는 `revoked`가 돼요. ## 구독 객체 ### 구독 객체 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `amount` | `number` | 예 | 갱신할 때 청구하는 주기당 금액의 상한을 원 단위 정수로 알려줘요. 상품 가격이 내려가면 다음 갱신부터 낮아지고, 인상은 고지 후 구매자가 동의해야 적용돼요. | | `9900` | | `billingInterval` | `string` | 예 | 구독 결제 주기예요. | `monthly` · `yearly` | `monthly` | | `buyerEmail` | `string` | 예 | 구매자 이메일 주소예요. | | `buyer@example.com` | | `buyerName` | `string` | 예 | 구매자 이름이에요. | | `홍길동` | | `cancelAtPeriodEnd` | `boolean` | 예 | 다음 기간 종료 시 해지하도록 예약했는지 알려줘요. | | `false` | | `currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `currentPeriodEnd` | `string` | 예 | 현재 결제 기간 종료 시각이에요. | | `2026-11-01T00:00:00.000Z` | | `currentPeriodStart` | `string` | 예 | 현재 결제 기간 시작 시각이에요. | | `2026-10-01T00:00:00.000Z` | | `endReason` | `string \| null` | 예 | 구독 종료 사유예요. | `canceled` · `refunded` · `payment_failed` · `product_unavailable` · `plan_unavailable` · `pg_canceled` · `price_increase_declined` · `null` | `null` | | `endedAt` | `string \| null` | 예 | 구독이 종료된 시각이에요. | | `null` | | `history` | `array of object \| null` | 예 | 구독 변경 이력이에요. | | | | `history[].at` | `string` | 예 | 변경이 기록된 시각이에요. | | `2026-10-15T00:00:00.000Z` | | `history[].effectiveAt` | `string \| null` | 예 | 변경 효력이 발생하는 시각이에요. | | `null` | | `history[].extensionDays` | `number \| null` | 예 | 구독 기간 연장 일수예요. | | `null` | | `history[].from` | `object \| null` | 예 | 변경 전 플랜과 주기예요. | | | | `history[].from.billingInterval` | `string` | 예 | 변경 전후 청구 주기예요. | `monthly` · `yearly` | `monthly` | | `history[].from.planId` | `string` | 예 | 변경 전후 구독 플랜 식별자예요. | | `plan_basic` | | `history[].note` | `string \| null` | 예 | 이전 변경 기록의 메모예요. | | `null` | | `history[].orderId` | `string \| null` | 예 | 변경과 연결된 주문 식별자예요. | | `null` | | `history[].prorationAmount` | `number \| null` | 예 | 차액 결제 금액, 또는 갱신·가격 변경 이력의 금액을 원 단위로 알려줘요. | | `null` | | `history[].to` | `object \| null` | 예 | 변경 후 플랜과 주기예요. | | | | `history[].to.billingInterval` | `string` | 예 | 변경 전후 청구 주기예요. | `monthly` · `yearly` | `monthly` | | `history[].to.planId` | `string` | 예 | 변경 전후 구독 플랜 식별자예요. | | `plan_basic` | | `history[].type` | `string \| null` | 예 | 내부 변경 기록 유형이에요. | `created` · `renewed` · `upgraded` · `downgrade_scheduled` · `downgrade_applied` · `interval_changed` · `cancel_scheduled` · `cancel_undone` · `ended` · `refunded` · `canceled_with_refund` · `comp_upgraded` · `period_extended` · `payment_failed` · `price_change_noticed` · `price_change_consented` · `null` | `renewed` | | `id` | `string` | 예 | 구독 식별자예요. | | `sub_123` | | `mode` | `string` | 예 | test 또는 live 구독 환경을 알려줘요. | `live` · `test` | `test` | | `pastDueSince` | `string \| null` | 예 | 결제 실패 상태가 시작된 시각이에요. | | `null` | | `pendingPriceChange` | `object \| null` | 예 | 가격 인상 고지와 구매자 동의 상태예요. | | | | `pendingPriceChange.amount` | `number` | 예 | 고지된 새 구독 금액을 원 단위로 알려줘요. | | `12000` | | `pendingPriceChange.consentedAt` | `string \| null` | 예 | 구매자가 새 가격에 동의한 시각이에요. | | `null` | | `pendingPriceChange.currency` | `string` | 예 | 고지된 금액의 통화예요. | `KRW` | `KRW` | | `pendingPriceChange.effectiveFrom` | `string` | 예 | 새 가격이 적용될 갱신일이에요. | | `2026-11-01T00:00:00.000Z` | | `pendingPriceChange.noticedAt` | `string` | 예 | 가격 인상을 고지한 시각이에요. | | `2026-10-01T00:00:00.000Z` | | `planId` | `string` | 예 | 구독 플랜 식별자예요. | | `plan_basic` | | `productId` | `string` | 예 | 상품 식별자예요. | | `prod_123` | | `productName` | `string` | 예 | 상품 이름이에요. | | `멤버십` | | `refundEligibleUntil` | `string` | 예 | 최초 결제 환불 가능 기한이에요. | | `2026-10-08T00:00:00.000Z` | | `refundTerms` | `object` | 예 | 구독 환불 조건의 스냅샷이에요. | | | | `refundTerms.dayRounding` | `string \| null` | 예 | 일수 계산 방식이에요. | `ceil` · `floor` · `null` | `null` | | `refundTerms.initialFullRefundDays` | `number \| null` | 예 | 최초 결제 전액 환불 가능 일수예요. | | `null` | | `refundTerms.listAmounts` | `object \| null` | 예 | 환불 계산에 사용하는 기준 금액이에요. | | | | `refundTerms.listAmounts.currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `refundTerms.listAmounts.monthly` | `number \| null` | 예 | 월간 기준 금액이에요. | | `null` | | `refundTerms.listAmounts.yearly` | `number \| null` | 예 | 연간 기준 금액이에요. | | `null` | | `refundTerms.penalty` | `object \| null` | 예 | 환불 위약금 조건이에요. | | | | `refundTerms.penalty.currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `refundTerms.penalty.fixedAmount` | `number \| null` | 예 | 고정 위약금을 원 단위로 알려줘요. | | `null` | | `refundTerms.penalty.rateBps` | `number \| null` | 예 | 위약금 비율을 1만분율로 알려줘요. | | `null` | | `refundTerms.policyType` | `string \| null` | 예 | 구독 환불 정책 유형이에요. | `no_refund` · `before_delivery` · `within_7days` · `custom` · `null` | `null` | | `refundTerms.renewalFullRefundDays` | `number \| null` | 예 | 갱신 결제 전액 환불 가능 일수예요. | | `null` | | `refundTerms.requireApproval` | `boolean \| null` | 예 | 환불 요청에 승인이 필요한지 알려줘요. | | `false` | | `refundTerms.restrictRepeatFullRefund` | `boolean \| null` | 예 | 반복 전액 환불 제한 여부예요. | | `null` | | `refundTerms.yearlyDiscountClawback` | `boolean \| null` | 예 | 연간 할인 환수 여부예요. | | `null` | | `scheduledChange` | `object \| null` | 예 | 예약된 구독 변경 정보예요. | | | | `scheduledChange.billingInterval` | `string \| null` | 예 | 예약된 구독 청구 주기예요. | `monthly` · `yearly` · `null` | `null` | | `scheduledChange.effectiveAt` | `string` | 예 | 예약 변경 효력이 발생하는 시각이에요. | | `2026-11-01T00:00:00.000Z` | | `scheduledChange.planId` | `string \| null` | 예 | 예약된 구독 플랜 식별자예요. | | `null` | | `scheduledChange.type` | `string` | 예 | 예약 변경 유형이에요. | `downgrade` · `interval_change` · `cancel` | `cancel` | | `startedAt` | `string` | 예 | 구독이 시작된 시각이에요. | | `2026-10-01T00:00:00.000Z` | | `status` | `string` | 예 | 구독의 공개 상태예요. past_due 는 결제가 밀린 상태예요. 결제가 밀린 채 해지를 예약해도 past_due 이고 cancelAtPeriodEnd 가 true 예요. | `active` · `past_due` · `ended` | `active` | | `testRenewalOutcome` | `string \| null` | 예 | test 갱신 결과 설정이에요. | `succeed` · `fail` · `null` | `succeed` | ### endReason 값 | `endReason` 값 | 의미 | |---|---| | `canceled` | 해지로 종료됐어요. | | `refunded` | 환불로 종료됐어요. | | `payment_failed` | 갱신 결제 실패로 종료됐어요. | | `product_unavailable` | 상품을 더 이상 제공할 수 없어 종료됐어요. | | `plan_unavailable` | 플랜을 더 이상 제공할 수 없어 종료됐어요. | | `pg_canceled` | PG 측 취소로 종료됐어요. | | `price_increase_declined` | 구매자가 가격 인상에 동의하지 않아 종료됐어요. | | `null` | 종료 사유가 아직 없어요. | ### 갱신 결제 실패 | 갱신 결제 | 결과 | |---|---| | 1~3번째 실패 | `past_due`가 되고 24시간 뒤 다시 결제해요. 실패마다 `subscription.payment_failed`가 와요. | | 4번째 실패 | `ended`(`endReason: payment_failed`)로 끝나요. 첫 실패부터 약 72시간이에요. | | `past_due` 중 기간말 해지 예약 | 더 결제하지 않고 곧 `ended`(`endReason: canceled`)로 끝나요. | 결제 수단은 구독을 시작할 때 정해지고 바꿀 수 없어요. ### 가격 변경 상품 가격을 올리면 구독자에게 고지되고, 구독 객체의 `pendingPriceChange`에 새 금액과 적용일(`effectiveFrom`)이 담겨요. | 갱신 | 결과 | |---|---| | 적용일 전 | 기존 금액으로 결제돼요. | | 적용일, 구매자가 동의함 | 새 금액으로 결제돼요. | | 적용일, 동의하지 않음 | 결제 없이 `ended`(`endReason: price_increase_declined`)로 끝나요. | 가격을 내리면 다음 갱신부터 내린 금액으로 결제돼요. --- Source: https://docs.payri.kr/developers/rest-api/portal-sessions # Portal Sessions 구매자 포털 세션은 구매자가 구독을 관리하도록 잠시 사용할 URL을 발급해요. #### 엔드포인트 - [POST /v1/portal-sessions](https://docs.payri.kr/developers/rest-api/portal-sessions#포털-세션-생성) ## 포털 세션 생성 **POST https://api.payri.kr/v1/portal-sessions** 구독 식별자 또는 구매자 이메일로 제한된 구매자 포털 세션을 만들어요. Idempotency-Key는 24시간 보관해요. API 키 인증과 `subscriptions:write` 권한이 필요해요. `Idempotency-Key` 헤더가 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `Idempotency-Key (header)` | `string` | 예 | POST 요청의 중복 실행을 막는 키예요. 24시간 보관하며 본문이 다른 요청에는 같은 키를 쓸 수 없어요. | 1~255자 | | #### 요청 본문 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `buyerEmail` | `string` | 아니요 | 구매자 이메일로 활성 구독을 찾아요. | 이메일 | `buyer@example.com` | | `returnUrl` | `string` | 아니요 | 포털에서 돌아갈 때 사용할 URL이에요. | | `https://shop.example.com/account` | | `subscriptionId` | `string` | 아니요 | 포털 세션을 만들 구독 식별자예요. | | `sub_123` | #### 요청 예시 ```bash curl -X POST https://api.payri.kr/v1/portal-sessions \ -H "Authorization: Bearer $PAYRI_API_KEY" \ -H "Idempotency-Key: portal-sub-123-001" \ -H "Content-Type: application/json" \ -d '{ "returnUrl": "https://shop.example.com/account", "subscriptionId": "sub_123" }' ``` #### 성공 응답 필드 (HTTP 201) | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `expiresAt` | `string` | 예 | 토큰 만료 시각이에요. | | `2026-10-15T01:00:00.000Z` | | `mode` | `string` | 예 | test 또는 live 포털 환경을 알려줘요. | `live` · `test` | `live` | | `token` | `string` | 예 | 포털 인증 토큰이에요. | | `eyJhbGciOi...` | | `url` | `string` | 예 | 구매자 포털에 접속할 URL이에요. | | `https://payri.kr/portal#token=...` | #### 성공 응답 예시 ```json { "expiresAt": "2026-10-15T01:00:00.000Z", "mode": "test", "token": "eyJhbGciOi...", "url": "https://payri.kr/portal#token=eyJhbGciOi..." } ``` #### 오류 코드 | 코드 | HTTP | 의미 | | --- | ---: | --- | | `SUBSCRIPTION_NOT_FOUND` | 404 | 구독이 없거나 키의 상품 범위 밖이에요. | | `CHECKOUT_URL_INVALID` | 400 | Checkout 또는 포털 복귀 URL 형식이 올바르지 않아요. | | `RETURN_URL_NOT_ALLOWED` | 400 | 복귀 URL 도메인이 결제 모드별 허용 목록에 없어요. | 표에 없는 공통 오류(인증·권한, 호출 한도, 입력 검증, 경로 없음, 멱등 키, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. **알아둘 점** - `subscriptionId` 또는 `buyerEmail`을 보내고, 둘 다 보내면 이메일이 구독 구매자와 일치해야 하며 `returnUrl`은 등록한 리디렉트 호스트만 사용할 수 있어요. - 세션은 60분 유효하고 API 키의 mode·상품 범위를 따르며, 범위 설정은 [IP · 상품 제한](https://docs.payri.kr/developers/api-keys/restrictions)에서 확인해요. - 가격 인상 동의, 구독 환불 요청 철회, 즉시 과금 변경(업그레이드·월간→연간 전환)은 구매자가 이메일 인증번호를 확인한 뒤에만 할 수 있어요. 포털이 인증 단계를 자동으로 띄워요. - 포털에서 구매자는 해지(기간말·즉시), 해지 예약 취소, 플랜·결제 주기 변경, 가격 인상 동의를 해요. 플랜 변경은 포털에서만 할 수 있어요. - 업그레이드와 월간→연간 전환은 바로 적용되고 차액을 청구해요. 다운그레이드와 연간→월간 전환은 기간말에 적용돼요. --- Source: https://docs.payri.kr/developers/rest-api/licenses # Licenses 서버에서 발급된 라이선스와 활성화 정보를 조회해요. #### 엔드포인트 - [GET /v1/licenses](https://docs.payri.kr/developers/rest-api/licenses#라이선스-목록) - [GET /v1/licenses/{id}](https://docs.payri.kr/developers/rest-api/licenses#라이선스-단건) ## 라이선스 목록 **GET https://api.payri.kr/v1/licenses** API 키의 test 또는 live 환경과 상품 범위에 속한 라이선스를 필터와 함께 조회해요. API 키 인증과 `licenses:read` 권한이 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `page (query)` | `number` | 아니요 | 조회할 페이지 번호예요. | 1 이상 | `1` | | `limit (query)` | `number` | 아니요 | 페이지마다 조회할 항목 수예요. | 1~100 | `20` | | `status (query)` | `string` | 아니요 | 라이선스 상태로 결과를 좁혀요. | `active` · `expired` · `revoked` · `pending` · `failed` | `active` | | `productId (query)` | `string` | 아니요 | 상품 식별자로 라이선스를 좁혀요. | | `prod_123` | | `key (query)` | `string` | 아니요 | 라이선스 키로 결과를 좁혀요. | | `PAYRI-ABCDE-FGHJK-MNPQR-STUVW` | | `orderId (query)` | `string` | 아니요 | 주문 식별자로 라이선스를 좁혀요. | | `ord_123` | | `buyerEmail (query)` | `string` | 아니요 | 구매자 이메일 문자열로 라이선스를 좁혀요. | | `buyer@example.com` | #### 요청 예시 ```bash curl 'https://api.payri.kr/v1/licenses?page=1&limit=20&status=active&productId=prod_123' \ -H "Authorization: Bearer $PAYRI_API_KEY" ``` 응답: HTTP 200, [라이선스 객체](https://docs.payri.kr/developers/rest-api/licenses#라이선스-객체) 목록, 형식 `{ data, total, page, limit }` #### 성공 응답 예시 ```json { "data": [ { "activationCount": 1, "activations": [ { "activatedAt": "2026-10-15T00:02:00.000Z", "deactivatedAt": null, "id": "act_123", "label": "내 컴퓨터", "lastVerifiedAt": "2026-10-15T00:02:30.000Z" } ], "buyerEmail": "buyer@example.com", "buyerName": "홍길동", "expiresAt": null, "id": "lic_123", "issuedAt": "2026-10-15T00:01:00.000Z", "key": "PAYRI-TEST-ABCDE-FGHJK-MNPQR-STUVW", "maxActivations": null, "mode": "test", "orderId": "ord_123", "orderItemId": "item_123", "productId": "prod_123", "productName": "디지털 가이드", "revokeReason": null, "revokedAt": null, "rotatedFrom": null, "status": "active" } ], "limit": 20, "page": 1, "total": 1 } ``` 표에 없는 공통 오류(인증·권한, 호출 한도, 입력 검증, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. ## 라이선스 단건 **GET https://api.payri.kr/v1/licenses/{id}** 라이선스 식별자로 발급 정보와 활성화 정보를 조회해요. API 키 인증과 `licenses:read` 권한이 필요해요. #### 파라미터 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `id (path)` | `string` | 예 | 조회할 라이선스 식별자예요. | | `lic_123` | #### 요청 예시 ```bash curl https://api.payri.kr/v1/licenses/lic_123 \ -H "Authorization: Bearer $PAYRI_API_KEY" ``` 응답: HTTP 200, [라이선스 객체](https://docs.payri.kr/developers/rest-api/licenses#라이선스-객체) #### 성공 응답 예시 ```json { "activationCount": 1, "activations": [ { "activatedAt": "2026-10-15T00:02:00.000Z", "deactivatedAt": null, "id": "act_123", "label": "내 컴퓨터", "lastVerifiedAt": "2026-10-15T00:02:30.000Z" } ], "buyerEmail": "buyer@example.com", "buyerName": "홍길동", "expiresAt": null, "id": "lic_123", "issuedAt": "2026-10-15T00:01:00.000Z", "key": "PAYRI-TEST-ABCDE-FGHJK-MNPQR-STUVW", "maxActivations": null, "mode": "test", "orderId": "ord_123", "orderItemId": "item_123", "productId": "prod_123", "productName": "디지털 가이드", "revokeReason": null, "revokedAt": null, "rotatedFrom": null, "status": "active" } ``` #### 오류 코드 | 코드 | HTTP | 의미 | | --- | ---: | --- | | `LICENSE_NOT_FOUND` | 404 | 라이선스가 없거나 키의 상품 범위 밖이에요. | 표에 없는 공통 오류(인증·권한, 호출 한도, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. **알아둘 점** - PayRi 자동 발급 키는 live에서 `PAYRI-XXXXX-XXXXX-XXXXX-XXXXX`, test에서 `PAYRI-TEST-XXXXX-XXXXX-XXXXX-XXXXX` 형식이며 대문자 영숫자에서 `0`, `O`, `1`, `I`, `L`은 제외해요. Test 주문의 키 목록·공용 키 상품에도 `PAYRI-TEST-`로 시작하는 키를 사용해요. 앱 API는 [라이선스 앱 연동](https://docs.payri.kr/developers/licensing/app-api)을 확인해요. ## 라이선스 객체 ### 라이선스 객체 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `activationCount` | `number` | 예 | 현재 활성화 수예요. | | `1` | | `activations` | `array of object \| null` | 예 | 이 라이선스의 활성화 정보예요. | | | | `activations[].activatedAt` | `string` | 예 | 활성화가 시작된 시각이에요. | | `2026-10-15T00:00:00.000Z` | | `activations[].deactivatedAt` | `string \| null` | 예 | 활성화가 해제된 시각이에요. | | `null` | | `activations[].id` | `string` | 예 | 활성화 식별자예요. | | `act_123` | | `activations[].label` | `string \| null` | 예 | 앱이 지정한 기기 이름이에요. | | `내 컴퓨터` | | `activations[].lastVerifiedAt` | `string \| null` | 예 | 마지막 검증 시각이에요. | | `null` | | `buyerEmail` | `string` | 예 | 구매자 이메일 주소예요. | | `buyer@example.com` | | `buyerName` | `string` | 예 | 구매자 이름이에요. | | `홍길동` | | `expiresAt` | `string \| null` | 예 | 라이선스 만료 시각이에요. | | `null` | | `id` | `string` | 예 | 라이선스 식별자예요. | | `lic_123` | | `issuedAt` | `string` | 예 | 라이선스 발급 시각이에요. | | `2026-10-15T00:00:00.000Z` | | `key` | `string \| null` | 예 | 발급된 라이선스 키예요. | | `PAYRI-ABCDE-FGHJK-MNPQR-STUVW` | | `maxActivations` | `number \| null` | 예 | 허용된 최대 활성화 수예요. 무제한이면 null이에요. | | `null` | | `mode` | `string` | 예 | test 또는 live 라이선스 환경을 알려줘요. | `live` · `test` | `live` | | `orderId` | `string` | 예 | 주문 식별자예요. | | `ord_123` | | `orderItemId` | `string \| null` | 예 | 주문 항목 식별자예요. | | `item_123` | | `productId` | `string` | 예 | 상품 식별자예요. | | `prod_123` | | `productName` | `string` | 예 | 상품 이름이에요. | | `디지털 가이드` | | `revokeReason` | `string \| null` | 예 | 라이선스 취소 사유예요. | | `null` | | `revokedAt` | `string \| null` | 예 | 라이선스가 취소된 시각이에요. | | `null` | | `rotatedFrom` | `string \| null` | 예 | 교체 전 라이선스 식별자예요. | | `null` | | `status` | `string` | 예 | 라이선스 상태예요. | `active` · `pending` · `expired` · `revoked` | `active` | --- Source: https://docs.payri.kr/developers/rest-api/errors # 에러 코드 HTTP 상태 코드와 본문 `code`로 에러를 분기해요. ## 응답 형식 ```json { "statusCode": 403, "code": "API_KEY_SCOPE_MISSING", "message": "이 요청에 필요한 권한이 없어요.", "details": { "required": ["refunds:write"] } } ``` | 필드 | 타입 | 설명 | |---|---|---| | `statusCode` | integer | HTTP 상태 코드예요. | | `code` | string | 에러 분기에 사용하는 코드예요. | | `message` | string | 상황을 설명하는 한국어 메시지예요. | | `details` | object, 선택 | 필드 검증 오류나 필요한 권한 등 추가 정보예요. 내용이 없으면 생략하거나 빈 객체일 수 있어요. | ## 전체 에러 코드 ### 일반 오류 · HTTP 400 | 코드 | 의미 | 발생 엔드포인트 | | --- | --- | --- | | `BAD_REQUEST` | 요청 필드를 확인해 주세요. | POST /v1/portal-sessions | | `VALIDATION_FAILED` | 필드 누락, 형식 오류, 지원하지 않는 필드 또는 필수 멱등 키 오류예요. | 공통 | ### 일반 오류 · HTTP 401 | 코드 | 의미 | 발생 엔드포인트 | | --- | --- | --- | | `API_KEY_INVALID` | API 키가 없거나 형식이 올바르지 않아요. 등록되지 않았거나 회전 유예가 끝난 키도 사용할 수 없어요. | 공통 | ### 일반 오류 · HTTP 404 | 코드 | 의미 | 발생 엔드포인트 | | --- | --- | --- | | `NOT_FOUND` | 요청한 리소스를 찾을 수 없어요. | POST /v1/portal-sessions | ### 일반 오류 · HTTP 429 | 코드 | 의미 | 발생 엔드포인트 | | --- | --- | --- | | `RATE_LIMITED` | API 키 호출 한도를 초과했어요. 잠시 후 다시 시도해 주세요. | 공통 | ### 일반 오류 · HTTP 500 | 코드 | 의미 | 발생 엔드포인트 | | --- | --- | --- | | `INTERNAL_ERROR` | 요청을 처리하는 중 오류가 발생했어요. | 공통 | ### 리소스별 오류 · HTTP 400 | 코드 | 의미 | 발생 엔드포인트 | | --- | --- | --- | | `CHECKOUT_URL_INVALID` | Checkout 또는 포털 복귀 URL 형식이 올바르지 않아요. | POST /v1/checkout/sessions, POST /v1/portal-sessions | | `METADATA_INVALID` | 메타데이터가 평면 문자열 맵이 아니거나 허용된 크기를 벗어났어요. | POST /v1/checkout/sessions | | `REFUND_AMOUNT_EXCEEDS` | 요청한 환불 금액이 현재 환불 가능액보다 커요. | POST /v1/refund-requests/{id}/approve | | `REFUND_AMOUNT_INVALID` | 환불 금액은 0보다 커야 해요. | POST /v1/orders/{id}/refund, POST /v1/refund-requests/{id}/approve | | `REFUND_EXCEEDS` | 환불 금액이 남은 환불 가능액보다 커요. | POST /v1/orders/{id}/refund, POST /v1/refund-requests/{id}/approve | | `REFUND_REASON_REQUIRED` | 환불 요청을 보류하거나 거절하는 사유를 입력해 주세요. | POST /v1/refund-requests/{id}/hold, POST /v1/refund-requests/{id}/reject | | `RETURN_URL_NOT_ALLOWED` | 복귀 URL 도메인이 결제 모드별 허용 목록에 없어요. | POST /v1/checkout/sessions, POST /v1/portal-sessions | | `SUBSCRIPTION_REFUND_AMOUNT_INVALID` | 승인한 구독 환불 금액이 유효하지 않아요. | POST /v1/refund-requests/{id}/approve | ### 리소스별 오류 · HTTP 403 | 코드 | 의미 | 발생 엔드포인트 | | --- | --- | --- | | `API_KEY_IP_NOT_ALLOWED` | 허용된 IP 주소에서 요청해야 해요. | 여러 엔드포인트 공통(19개) | | `API_KEY_SCOPE_MISSING` | 요청에 필요한 권한이 없어요. | 여러 엔드포인트 공통(19개) | ### 리소스별 오류 · HTTP 404 | 코드 | 의미 | 발생 엔드포인트 | | --- | --- | --- | | `CHECKOUT_SESSION_NOT_FOUND` | Checkout 세션이 없거나 키의 범위 밖이에요. | GET /v1/checkout/sessions/{id} | | `LICENSE_NOT_FOUND` | 라이선스가 없거나 키의 상품 범위 밖이에요. | GET /v1/licenses/{id} | | `ORDER_NOT_FOUND` | 주문이 없거나 키의 상품 범위 밖이에요. | GET /v1/orders/{id}, POST /v1/orders/{id}/refund, POST /v1/refund-requests/{id}/approve, POST /v1/subscriptions/{id}/cancel | | `PRODUCT_NOT_FOUND` | 상품이 없거나 키의 상품 범위 밖이에요. | POST /v1/checkout/sessions, GET /v1/products/{id} | | `REFUND_REQUEST_NOT_FOUND` | 환불 요청이 없거나 키의 상품 범위 밖이에요. | GET /v1/refund-requests/{id}, POST /v1/refund-requests/{id}/approve, POST /v1/refund-requests/{id}/hold, POST /v1/refund-requests/{id}/reject | | `SUBSCRIPTION_NOT_FOUND` | 구독이 없거나 키의 상품 범위 밖이에요. | POST /v1/portal-sessions, GET /v1/subscriptions/{id}, POST /v1/subscriptions/{id}/cancel, GET /v1/subscriptions/{id}/cancel-preview | ### 리소스별 오류 · HTTP 409 | 코드 | 의미 | 발생 엔드포인트 | | --- | --- | --- | | `ALREADY_CANCELED` | 이미 기간말 해지가 예약돼 있어요. | POST /v1/subscriptions/{id}/cancel | | `ALREADY_ENDED` | 이미 종료된 구독이에요. | POST /v1/subscriptions/{id}/cancel | | `ENDED` | 판매가 종료된 상품이에요. | POST /v1/checkout/sessions | | `IDEMPOTENCY_IN_PROGRESS` | 같은 멱등 키의 요청이 처리 중이에요. 잠시 후 다시 시도해 주세요. | 여러 엔드포인트 공통(7개) | | `IDEMPOTENCY_KEY_CONFLICT` | 같은 멱등 키를 다른 요청에 사용했어요. | 여러 엔드포인트 공통(7개) | | `NOT_STARTED` | 아직 판매가 시작되지 않은 상품이에요. | POST /v1/checkout/sessions | | `PRODUCT_ARCHIVED` | 보관된 상품은 결제할 수 없어요. | POST /v1/checkout/sessions | | `PUBLIC_CONTACT_VERIFICATION_REQUIRED` | Checkout 세션을 만들기 전에 공개 연락처를 인증해 주세요. | POST /v1/checkout/sessions | | `REFUND_AMOUNT_MISMATCH` | 승인 금액이 요청 금액과 달라요. 금액을 확인하고 차이를 명시적으로 승인해 주세요. | POST /v1/refund-requests/{id}/approve | | `REFUND_IN_PROGRESS` | 이 주문과 관련한 환불이 처리 중이에요. | POST /v1/orders/{id}/refund, POST /v1/refund-requests/{id}/approve, POST /v1/subscriptions/{id}/cancel | | `REFUND_NONE_LEFT` | 남은 환불 가능 금액이 없어요. | POST /v1/orders/{id}/refund, POST /v1/refund-requests/{id}/approve | | `REFUND_NOT_PAID` | 주문 상태가 결제 완료가 아니에요. | POST /v1/orders/{id}/refund, POST /v1/refund-requests/{id}/approve | | `REFUND_REQUEST_NOT_ACTIONABLE` | 환불 요청의 현재 상태에서는 승인·거절·보류할 수 없어요. | POST /v1/refund-requests/{id}/approve, POST /v1/refund-requests/{id}/hold, POST /v1/refund-requests/{id}/reject | | `REFUND_REQUEST_PENDING` | 활성 환불 요청이 있어 주문을 환불할 수 없어요. | POST /v1/orders/{id}/refund, POST /v1/subscriptions/{id}/cancel | | `REFUND_SETTLED` | 정산 회차에 포함된 주문은 환불할 수 없어요. | POST /v1/orders/{id}/refund, POST /v1/refund-requests/{id}/approve | | `REFUND_STATUTORY_LOCKED` | 법정 청약철회 기간에는 현재 작업자의 처리가 제한돼요. | POST /v1/refund-requests/{id}/approve, POST /v1/refund-requests/{id}/hold, POST /v1/refund-requests/{id}/reject | | `SOLD_OUT` | 이 상품은 품절되어 결제할 수 없어요. | POST /v1/checkout/sessions | | `SUBSCRIPTION_REFUND_NONE_LEFT` | 구독 주문에 남은 환불 가능 금액이 없어요. | POST /v1/refund-requests/{id}/approve, POST /v1/subscriptions/{id}/cancel | | `SUBSCRIPTION_REFUND_SETTLED` | 즉시 해지 환불 대상 주문이 정산 회차에 포함됐어요. | POST /v1/refund-requests/{id}/approve, POST /v1/subscriptions/{id}/cancel | ### 리소스별 오류 · HTTP 429 | 코드 | 의미 | 발생 엔드포인트 | | --- | --- | --- | | `LICENSE_LOOKUP_BLOCKED` | 라이선스 조회 제한에 걸려 요청할 수 없어요. Retry-After 이후 다시 시도해 주세요. | POST /v1/licenses/activate, POST /v1/licenses/deactivate, POST /v1/licenses/verify | | `LICENSE_RATE_LIMITED` | 라이선스 API 호출 한도를 넘었어요. Retry-After 이후 다시 시도해 주세요. | POST /v1/licenses/activate, POST /v1/licenses/deactivate, POST /v1/licenses/verify | ### 리소스별 오류 · HTTP 500 | 코드 | 의미 | 발생 엔드포인트 | | --- | --- | --- | | `CHECKOUT_NOT_CONFIGURED` | 스토어의 결제 설정이 완료되지 않아 Checkout 세션을 만들 수 없어요. | POST /v1/checkout/sessions | ### 리소스별 오류 · HTTP 502 | 코드 | 의미 | 발생 엔드포인트 | | --- | --- | --- | | `PG_REFUND_AMBIGUOUS` | 결제 대행사의 환불 결과를 확인하지 못했어요. 실제 환불됐을 수 있으니 상태를 확인한 뒤 다시 처리해 주세요. | POST /v1/orders/{id}/refund, POST /v1/refund-requests/{id}/approve, POST /v1/subscriptions/{id}/cancel | | `PG_REFUND_FAILED` | 결제 대행사가 환불 요청을 처리하지 못했어요. | POST /v1/orders/{id}/refund, POST /v1/refund-requests/{id}/approve, POST /v1/subscriptions/{id}/cancel | ## 입력 검증 실패 필드 누락·형식 오류·미지원 필드는 `400 VALIDATION_FAILED`이며, `details.fields`에 `field`와 `message`가 담겨요. ```json { "statusCode": 400, "code": "VALIDATION_FAILED", "message": "요청 값을 확인해주세요.", "details": { "fields": [ { "field": "successUrl", "message": "URL 형식이 올바르지 않아요." } ] } } ``` ```json { "statusCode": 400, "code": "VALIDATION_FAILED", "message": "요청 값을 확인해주세요.", "details": { "fields": [ { "field": "Idempotency-Key", "message": "필수 값이에요." } ] } } ``` ## 멱등성 충돌 멱등 키의 응답 재생과 충돌 처리는 [REST API 개요의 멱등성](https://docs.payri.kr/developers/rest-api#멱등성)에서 확인해요. ## 라이선스 앱 연동 API | 상황 | 응답 | 응답 서명 | |---|---|---| | 정상 처리 | HTTP `200`과 `status` (없는 라이선스 키는 `status: "not_found"`) | `Payri-Signature` 있음 | | 요청 형식 오류 | `400 VALIDATION_FAILED` | 없음 | | 호출 제한 | `429 LICENSE_RATE_LIMITED` 또는 `429 LICENSE_LOOKUP_BLOCKED` | 없음 | 라이선스 앱 API는 API 키 대신 라이선스 키로 인증하고, 응답 서명은 [라이선스 앱 연동 API](https://docs.payri.kr/developers/licensing/app-api)에서 확인해요. ## 호출 제한 API 키 호출 제한은 [REST API 개요의 호출 제한](https://docs.payri.kr/developers/rest-api#호출-제한)에서 확인해요. ## 다시 보내기 POST 재시도는 [REST API 개요의 멱등성 규칙](https://docs.payri.kr/developers/rest-api#멱등성)을 따라요. ## 관련 코드 - [Checkout 오류](https://docs.payri.kr/developers/rest-api/checkout) - [주문 오류](https://docs.payri.kr/developers/rest-api/orders) - [환불 오류](https://docs.payri.kr/developers/rest-api/refunds) - [구독 오류](https://docs.payri.kr/developers/rest-api/subscriptions) - [라이선스 조회 오류](https://docs.payri.kr/developers/rest-api/licenses) --- Source: https://docs.payri.kr/developers/webhooks # 웹훅 웹훅은 결제·환불·구독 이벤트를 셀러 서버로 전달해요. ## 엔드포인트 등록 셀러 콘솔의 외부 연동에서 웹훅 수신 URL을 등록해요. 엔드포인트는 mode별로 등록하며, 이벤트는 같은 mode의 엔드포인트로만 전송돼요. 수신 URL은 공개된 http·https 주소여야 해요. localhost와 IP 주소는 등록되지 않고, 내부·사설·루프백·링크로컬 등 특수 주소로 해석되는 호스트에는 보내지 않아요. 로컬 개발은 공개 터널 주소를 사용해요. | 설정 | 설명 | |---|---| | mode | live 또는 test 이벤트를 받을 환경이에요. | | 이벤트 | 필요한 이벤트만 선택해요. 이벤트 목록은 [이벤트 레퍼런스](https://docs.payri.kr/developers/webhooks/events)를 참고하세요. | | 상품 범위 | 전체 상품 또는 지정한 상품의 이벤트를 받아요. | | license.issue | 내 서버에서 발급하는 상품의 동기 발급 요청을 받을지 설정해요. 같은 mode와 상품 범위에는 하나만 등록할 수 있어요. 범위가 겹치면 콘솔에서 409 LICENSE_ISSUE_ENDPOINT_CONFLICT로 거절돼요. [내 서버에서 발급](https://docs.payri.kr/developers/licensing/issue-webhook)을 참고하세요. | | 시크릿 | 엔드포인트별 whsec_… 값이에요. | ## 요청 본문 | 항목 | 내용 | |---|---| | 공통 필드 | id, type, createdAt, mode, data를 포함해요. | | mode | 본문과 data의 mode는 모두 live 또는 test예요. | | 일반 이벤트의 data | 이벤트 종류에 해당하는 v1 응답 객체 전체예요. 주문 이벤트는 [주문 API](https://docs.payri.kr/developers/rest-api/orders)의 주문 객체와 같은 형태예요. | | license.issue의 data | 발급 요청 전용 구성이며 [내 서버에서 발급](https://docs.payri.kr/developers/licensing/issue-webhook)에서 확인해요. | ```json { "id": "evt_01JY8N4X2K7M6Q", "type": "order.paid", "createdAt": "2026-10-15T03:20:05.000Z", "mode": "live", "data": { "id": "ord_3kf9a2", "mode": "live", "productId": "prod_88x", "productName": "MeetNote Solo", "amount": 19000, "currency": "KRW", "buyerName": "김구매", "buyerEmail": "buyer@example.com", "buyerPhone": null, "method": "card", "status": "paid", "createdAt": "2026-10-15T03:20:00.000Z", "paidAt": "2026-10-15T03:20:05.000Z", "refundedAt": null, "refundedAmount": 0, "refunds": [], "confirmedAt": null, "withdrawalNoticeAgreedAt": null, "coupon": null, "subscriptionId": "sub_2kf9a2", "planId": "plan_monthly", "billingInterval": "monthly", "subscriptionEvent": "initial", "periodStart": "2026-10-15T03:20:05.000Z", "periodEnd": "2026-11-15T03:20:05.000Z", "checkoutSessionId": null, "metadata": null } } ``` | 헤더 | 의미 | |---|---| | webhook-id | 본문 id와 같은 이벤트 ID예요. 재시도와 콘솔 재전송에도 바뀌지 않으며 멱등 키로 사용해요. | | webhook-timestamp | 서명에 포함되는 발송 시각이며, 콘솔에서 다시 보내면 새 값으로 바뀌어요. | | webhook-signature | 원문 본문의 서명이에요. 자세한 내용은 [서명 검증](https://docs.payri.kr/developers/webhooks/signature)을 참고하세요. | ## 응답과 처리 | 응답 | 판정 | |---|---| | 2xx 전체 | 성공이에요. 200만 성공 조건은 아니에요. | | 3xx 리다이렉트 | 따라가지 않고 실패로 처리해 재시도해요. | | 2xx 이외 | 실패로 처리해 재시도해요. | | 10초 초과 또는 네트워크 오류 | 실패로 처리해 재시도해요. | 서명을 검증하고 webhook-id로 중복을 거른 뒤 10초 안에 2xx로 응답해요. ```js const eventId = req.get('webhook-id') if (await store.hasProcessed(eventId)) { return res.sendStatus(202) } try { await queue.publish({ event: req.body, eventId }) } catch { return res.sendStatus(500) // 2xx를 주지 않아 다시 받아요. } await store.markProcessed(eventId) return res.sendStatus(202) ``` ## 전달 순서와 최신 상태 - 웹훅 전달 순서는 보장되지 않아요. - 순서대로 처리해야 하면 createdAt을 기준으로 정렬해요. - 중요한 처리 전에는 주문·구독 리소스를 다시 조회해 최신 상태를 확인해요. ## 테스트 발송 셀러 콘솔에서 webhook.test를 보내 연결·서명을 확인하고, 실제 결제 이벤트가 아니므로 주문·구독 처리에 사용하지 않아요. --- Source: https://docs.payri.kr/developers/webhooks/events # 이벤트 레퍼런스 전달 순서와 최신 상태는 [웹훅 개요](https://docs.payri.kr/developers/webhooks#전달-순서와-최신-상태), 중복 처리는 [응답과 처리](https://docs.payri.kr/developers/webhooks#응답과-처리)를 참고해요. | 이벤트 | 언제 와요 | data 객체 | 주의점 | |---|---|---|---| | order.paid | 결제가 성공할 때 와요. | 주문 객체 전체예요. | 구독 결제 유형은 아래에서 확인해요. | | order.refunded | 부분 또는 전체 환불이 확정될 때 와요. | 주문 객체 전체와 이번 환불 건을 나타내는 refund 객체예요. | — | | subscription.created | 새 구독이 만들어질 때 와요. | 구독 객체 전체와 change예요. change.kind는 항상 created예요. | 최초 결제 성공에 대한 order.paid도 별도로 올 수 있어요. | | subscription.updated | 플랜·결제 주기 변경, 해지 예약·취소, 구독 기간 갱신, 가격 인상 고지·동의 때 와요. | 구독 객체 전체와 change 객체예요. kind로 변경 종류를 구분해요. | 갱신 결제에서 order.paid와 함께 올 수 있어요. | | subscription.ended | 구독이 실제로 종료될 때 와요. | 종료된 구독 객체 전체와 endReason예요. | — | | subscription.payment_failed | 구독 갱신 결제가 실패할 때 와요. | 해당 구독 객체 전체와 실패 횟수 attempts, 실패 금액 amount, 통화 currency예요. | 구독 상태가 바뀔 수 있어요. | | refund.requested | 구매자의 환불 요청이 접수될 때 와요. | 환불 요청 객체 전체예요. | 요청을 검토하고 상태를 처리해요. | | refund.approved | 환불 요청이 승인될 때 와요. | 환불 요청 객체 전체예요. | — | | refund.rejected | 환불 요청이 거절될 때 와요. | 환불 요청 객체 전체예요. | 요청 상태와 거절 정보를 확인해요. | | refund.on_hold | 환불 요청이 보류될 때 와요. | 환불 요청 객체 전체예요. | 보류 사유와 최신 상태를 확인해요. | | refund.withdrawn | 구매자가 환불 요청을 철회할 때 와요. | 환불 요청 객체 전체예요. | — | | license.issue | 결제 확정 직후 내 서버에서 라이선스 키를 발급하도록 동기 요청해요. | 발급에 필요한 요청 객체예요. | data 필드와 응답 규칙은 [내 서버에서 발급](https://docs.payri.kr/developers/licensing/issue-webhook)을 참고해요. | | webhook.test | 셀러 콘솔에서 테스트 발송을 실행할 때 와요. | endpointId, mode, sentAt을 포함해요. | 테스트 발송 전용 표시이며 실제 업무 이벤트 type이 아니에요. live와 test 엔드포인트 모두로 보낼 수 있어요. | ## 결제와 구독 이벤트 ### order.paid 최초 결제, 갱신 또는 차액 결제가 완료되면 보내요. 본문과 data의 mode는 test 또는 live예요. 서명 헤더와 본문 형식은 [웹훅](https://docs.payri.kr/developers/webhooks)에서 확인해요. data는 [주문 객체](https://docs.payri.kr/developers/rest-api/orders#%EC%A3%BC%EB%AC%B8-%EA%B0%9D%EC%B2%B4)예요. #### 예시 ```json { "createdAt": "2026-10-15T00:00:00.000Z", "data": { "amount": 10000, "billingInterval": null, "buyerEmail": "buyer@example.com", "buyerName": "홍길동", "buyerPhone": "01012345678", "checkoutSessionId": "cs_123", "confirmedAt": null, "coupon": null, "createdAt": "2026-10-15T00:00:00.000Z", "currency": "KRW", "id": "ord_123", "metadata": { "campaign": "fall" }, "method": "card", "mode": "test", "paidAt": "2026-10-15T00:01:00.000Z", "periodEnd": null, "periodStart": null, "planId": null, "productId": "prod_123", "productName": "디지털 가이드", "refundedAmount": 0, "refundedAt": null, "refunds": [], "status": "paid", "subscriptionEvent": null, "subscriptionId": null, "withdrawalNoticeAgreedAt": "2026-10-15T00:00:00.000Z" }, "id": "evt_123", "mode": "test", "type": "order.paid" } ``` | data.subscriptionEvent | 의미 | |---|---| | null | 비구독 주문이에요. | | initial | 최초 구독 결제예요. | | renewal | 구독 갱신 결제예요. | | proration | 플랜·주기 변경 차액 결제예요. | ### subscription.created 새 구독이 생성되면 보내요. 본문과 data의 mode는 test 또는 live예요. 서명 헤더와 본문 형식은 [웹훅](https://docs.payri.kr/developers/webhooks)에서 확인해요. data는 [구독 객체](https://docs.payri.kr/developers/rest-api/subscriptions#%EA%B5%AC%EB%8F%85-%EA%B0%9D%EC%B2%B4)에 아래 필드를 더한 것이에요. #### 추가 data 필드 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `change` | `object` | 예 | 새 구독이 만들어진 변경 정보예요. | | | | `change.amount` | `number \| null` | 예 | 변경 시 적용된 금액을 원 단위로 알려줘요. | | `null` | | `change.currency` | `string \| null` | 예 | 변경 금액 통화예요. | `KRW` · `null` | `null` | | `change.effectiveAt` | `string \| null` | 예 | 변경 효력이 발생하는 시각이에요. | | `null` | | `change.extensionDays` | `number \| null` | 예 | 구독 기간 연장 일수예요. | | `null` | | `change.from` | `object \| null` | 예 | 변경 전 플랜과 주기예요. | | | | `change.from.billingInterval` | `string` | 예 | 변경 전후 청구 주기예요. | `monthly` · `yearly` | `monthly` | | `change.from.planId` | `string` | 예 | 변경 전후 구독 플랜 식별자예요. | | `plan_basic` | | `change.kind` | `string` | 예 | 이 이벤트에서 발생한 구독 변경 유형이에요. | `created` | | | `change.orderId` | `string \| null` | 예 | 변경과 연결된 주문 식별자예요. | | `null` | | `change.prorationAmount` | `number \| null` | 예 | 차액 결제 금액을 원 단위로 알려줘요. | | `null` | | `change.to` | `object \| null` | 예 | 변경 후 플랜과 주기예요. | | | | `change.to.billingInterval` | `string` | 예 | 변경 전후 청구 주기예요. | `monthly` · `yearly` | `monthly` | | `change.to.planId` | `string` | 예 | 변경 전후 구독 플랜 식별자예요. | | `plan_basic` | #### 예시 ```json { "createdAt": "2026-10-15T00:00:00.000Z", "data": { "amount": 9900, "billingInterval": "monthly", "buyerEmail": "buyer@example.com", "buyerName": "홍길동", "cancelAtPeriodEnd": false, "change": { "amount": null, "currency": null, "effectiveAt": null, "extensionDays": null, "from": null, "kind": "created", "orderId": null, "prorationAmount": null, "to": null }, "currency": "KRW", "currentPeriodEnd": "2026-11-01T00:00:00.000Z", "currentPeriodStart": "2026-10-01T00:00:00.000Z", "endReason": null, "endedAt": null, "history": [], "id": "sub_123", "mode": "test", "pastDueSince": null, "pendingPriceChange": null, "planId": "plan_basic", "productId": "prod_123", "productName": "멤버십", "refundEligibleUntil": "2026-10-08T00:00:00.000Z", "refundTerms": { "dayRounding": "ceil", "initialFullRefundDays": 7, "listAmounts": null, "penalty": null, "policyType": "within_7days", "renewalFullRefundDays": 7, "requireApproval": false, "restrictRepeatFullRefund": false, "yearlyDiscountClawback": false }, "scheduledChange": null, "startedAt": "2026-10-01T00:00:00.000Z", "status": "active", "testRenewalOutcome": "succeed" }, "id": "evt_123", "mode": "test", "type": "subscription.created" } ``` ### subscription.updated 플랜, 주기, 기간 또는 해지 예약이 변경되면 보내요. 본문과 data의 mode는 test 또는 live예요. 서명 헤더와 본문 형식은 [웹훅](https://docs.payri.kr/developers/webhooks)에서 확인해요. data는 [구독 객체](https://docs.payri.kr/developers/rest-api/subscriptions#%EA%B5%AC%EB%8F%85-%EA%B0%9D%EC%B2%B4)에 아래 필드를 더한 것이에요. #### 추가 data 필드 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `change` | `object` | 예 | 구독의 변경 전후 플랜 정보예요. | | | | `change.amount` | `number \| null` | 예 | 변경 시 적용된 금액을 원 단위로 알려줘요. | | `null` | | `change.currency` | `string \| null` | 예 | 변경 금액 통화예요. | `KRW` · `null` | `null` | | `change.effectiveAt` | `string \| null` | 예 | 변경 효력이 발생하는 시각이에요. | | `null` | | `change.extensionDays` | `number \| null` | 예 | 구독 기간 연장 일수예요. | | `null` | | `change.from` | `object \| null` | 예 | 변경 전 플랜과 주기예요. | | | | `change.from.billingInterval` | `string` | 예 | 변경 전후 청구 주기예요. | `monthly` · `yearly` | `monthly` | | `change.from.planId` | `string` | 예 | 변경 전후 구독 플랜 식별자예요. | | `plan_basic` | | `change.kind` | `string` | 예 | 이 이벤트에서 발생한 구독 변경 유형이에요. | `plan_changed` · `plan_change_scheduled` · `billing_interval_changed` · `billing_interval_change_scheduled` · `cancel_scheduled` · `cancel_resumed` · `period_renewed` · `period_extended` · `price_change_scheduled` · `price_change_consented` | | | `change.orderId` | `string \| null` | 예 | 변경과 연결된 주문 식별자예요. | | `null` | | `change.prorationAmount` | `number \| null` | 예 | 차액 결제 금액을 원 단위로 알려줘요. | | `null` | | `change.to` | `object \| null` | 예 | 변경 후 플랜과 주기예요. | | | | `change.to.billingInterval` | `string` | 예 | 변경 전후 청구 주기예요. | `monthly` · `yearly` | `monthly` | | `change.to.planId` | `string` | 예 | 변경 전후 구독 플랜 식별자예요. | | `plan_basic` | #### 예시 ```json { "createdAt": "2026-10-15T00:00:00.000Z", "data": { "amount": 9900, "billingInterval": "monthly", "buyerEmail": "buyer@example.com", "buyerName": "홍길동", "cancelAtPeriodEnd": false, "change": { "amount": 9900, "currency": "KRW", "effectiveAt": null, "extensionDays": null, "from": { "billingInterval": "monthly", "planId": "plan_basic" }, "kind": "plan_changed", "orderId": null, "prorationAmount": 0, "to": { "billingInterval": "monthly", "planId": "plan_pro" } }, "currency": "KRW", "currentPeriodEnd": "2026-11-01T00:00:00.000Z", "currentPeriodStart": "2026-10-01T00:00:00.000Z", "endReason": null, "endedAt": null, "history": [], "id": "sub_123", "mode": "test", "pastDueSince": null, "pendingPriceChange": null, "planId": "plan_pro", "productId": "prod_123", "productName": "멤버십", "refundEligibleUntil": "2026-10-08T00:00:00.000Z", "refundTerms": { "dayRounding": "ceil", "initialFullRefundDays": 7, "listAmounts": null, "penalty": null, "policyType": "within_7days", "renewalFullRefundDays": 7, "requireApproval": false, "restrictRepeatFullRefund": false, "yearlyDiscountClawback": false }, "scheduledChange": null, "startedAt": "2026-10-01T00:00:00.000Z", "status": "active", "testRenewalOutcome": "succeed" }, "id": "evt_123", "mode": "test", "type": "subscription.updated" } ``` | data.change.kind | 의미 | |---|---| | plan_changed | 플랜 변경이 적용됐어요. | | plan_change_scheduled | 플랜 변경이 예약됐어요. | | billing_interval_changed | 결제 주기 변경이 적용됐어요. | | billing_interval_change_scheduled | 결제 주기 변경이 예약됐어요. | | cancel_scheduled | 기간말 해지가 예약됐어요. | | cancel_resumed | 해지 예약이 취소됐어요. | | period_renewed | 갱신 결제로 구독 기간이 갱신됐어요. | | period_extended | 구독 기간이 연장됐어요. | | price_change_scheduled | 가격 인상이 고지됐어요. 새 금액은 change.amount, 적용일은 change.effectiveAt이에요. | | price_change_consented | 구매자가 인상된 가격에 동의했어요. | 권한은 change.kind가 아니라 구독 객체의 최신 상태로 맞춰요. ```js async function handleSubscriptionUpdated(event) { const subscription = event.data if (!(await saveSubscriptionIfNewer(subscription, event.createdAt))) return await syncAccessFromSubscription(subscription) switch (event.data.change.kind) { case 'cancel_scheduled': await sendMail('cancel-scheduled', subscription); break case 'cancel_resumed': await sendMail('cancel-resumed', subscription); break case 'plan_change_scheduled': case 'billing_interval_change_scheduled': await sendMail('subscription-change-scheduled', subscription); break default: break } } ``` ### subscription.ended 구독이 종료되면 보내요. 본문과 data의 mode는 test 또는 live예요. 서명 헤더와 본문 형식은 [웹훅](https://docs.payri.kr/developers/webhooks)에서 확인해요. data는 [구독 객체](https://docs.payri.kr/developers/rest-api/subscriptions#%EA%B5%AC%EB%8F%85-%EA%B0%9D%EC%B2%B4)에 아래 필드를 더한 것이에요. #### 추가 data 필드 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `endReason` | `string \| null` | 예 | 구독 종료 사유예요. | `canceled` · `refunded` · `payment_failed` · `product_unavailable` · `plan_unavailable` · `pg_canceled` · `price_increase_declined` · `null` | `null` | | `refundAmount` | `integer` | 아니요 | 즉시 해지로 환불한 금액이에요. 환불을 수반한 즉시 해지에서 전달해요. | | `10000` | #### 예시 ```json { "createdAt": "2026-10-15T00:00:00.000Z", "data": { "amount": 9900, "billingInterval": "monthly", "buyerEmail": "buyer@example.com", "buyerName": "홍길동", "cancelAtPeriodEnd": false, "currency": "KRW", "currentPeriodEnd": "2026-11-01T00:00:00.000Z", "currentPeriodStart": "2026-10-01T00:00:00.000Z", "endReason": "refunded", "endedAt": "2026-10-15T00:02:00.000Z", "history": [], "id": "sub_123", "mode": "test", "pastDueSince": null, "pendingPriceChange": null, "planId": "plan_basic", "productId": "prod_123", "productName": "멤버십", "refundAmount": 10000, "refundEligibleUntil": "2026-10-08T00:00:00.000Z", "refundTerms": { "dayRounding": "ceil", "initialFullRefundDays": 7, "listAmounts": null, "penalty": null, "policyType": "within_7days", "renewalFullRefundDays": 7, "requireApproval": false, "restrictRepeatFullRefund": false, "yearlyDiscountClawback": false }, "scheduledChange": null, "startedAt": "2026-10-01T00:00:00.000Z", "status": "ended", "testRenewalOutcome": "succeed" }, "id": "evt_123", "mode": "test", "type": "subscription.ended" } ``` 종료된 구독의 endReason 값과 의미는 [구독 REST API](https://docs.payri.kr/developers/rest-api/subscriptions)를 참고해요. ### subscription.payment_failed 구독 갱신 결제가 실패하면 보내요. 본문과 data의 mode는 test 또는 live예요. 서명 헤더와 본문 형식은 [웹훅](https://docs.payri.kr/developers/webhooks)에서 확인해요. data는 [구독 객체](https://docs.payri.kr/developers/rest-api/subscriptions#%EA%B5%AC%EB%8F%85-%EA%B0%9D%EC%B2%B4)에 아래 필드를 더한 것이에요. #### 추가 data 필드 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `attempts` | `integer` | 예 | 누적 결제 실패 횟수예요. | | `2` | #### 예시 ```json { "createdAt": "2026-10-15T00:00:00.000Z", "data": { "amount": 9900, "attempts": 2, "billingInterval": "monthly", "buyerEmail": "buyer@example.com", "buyerName": "홍길동", "cancelAtPeriodEnd": false, "currency": "KRW", "currentPeriodEnd": "2026-11-01T00:00:00.000Z", "currentPeriodStart": "2026-10-01T00:00:00.000Z", "endReason": null, "endedAt": null, "history": [], "id": "sub_123", "mode": "test", "pastDueSince": "2026-10-15T00:02:00.000Z", "pendingPriceChange": null, "planId": "plan_basic", "productId": "prod_123", "productName": "멤버십", "refundEligibleUntil": "2026-10-08T00:00:00.000Z", "refundTerms": { "dayRounding": "ceil", "initialFullRefundDays": 7, "listAmounts": null, "penalty": null, "policyType": "within_7days", "renewalFullRefundDays": 7, "requireApproval": false, "restrictRepeatFullRefund": false, "yearlyDiscountClawback": false }, "scheduledChange": null, "startedAt": "2026-10-01T00:00:00.000Z", "status": "past_due", "testRenewalOutcome": "fail" }, "id": "evt_123", "mode": "test", "type": "subscription.payment_failed" } ``` ## 환불 이벤트 ### order.refunded 부분 또는 전체 환불이 확정되면 보내요. 본문과 data의 mode는 test 또는 live예요. 서명 헤더와 본문 형식은 [웹훅](https://docs.payri.kr/developers/webhooks)에서 확인해요. data는 [주문 객체](https://docs.payri.kr/developers/rest-api/orders#%EC%A3%BC%EB%AC%B8-%EA%B0%9D%EC%B2%B4)에 아래 필드를 더한 것이에요. #### 추가 data 필드 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `refund` | `object` | 예 | 주문 환불의 상세 정보예요. | | | | `refund.amount` | `number` | 예 | 환불 금액을 원 단위로 알려줘요. | | `5000` | | `refund.currency` | `string` | 예 | 금액 통화를 알려줘요. | `KRW` | `KRW` | | `refund.id` | `string` | 예 | 환불 식별자예요. | | `ref_123` | | `refund.reason` | `string \| null` | 예 | 환불 사유를 알려줘요. | | `고객 요청` | | `refund.refundedAt` | `string` | 예 | 환불이 완료된 시각이에요. | | `2026-10-15T00:00:00.000Z` | #### 예시 ```json { "createdAt": "2026-10-15T00:00:00.000Z", "data": { "amount": 10000, "billingInterval": null, "buyerEmail": "buyer@example.com", "buyerName": "홍길동", "buyerPhone": "01012345678", "checkoutSessionId": "cs_123", "confirmedAt": null, "coupon": null, "createdAt": "2026-10-15T00:00:00.000Z", "currency": "KRW", "id": "ord_123", "metadata": { "campaign": "fall" }, "method": "card", "mode": "test", "paidAt": "2026-10-15T00:01:00.000Z", "periodEnd": null, "periodStart": null, "planId": null, "productId": "prod_123", "productName": "디지털 가이드", "refund": { "amount": 5000, "currency": "KRW", "id": "ref_123", "reason": "고객 요청", "refundedAt": "2026-10-15T00:02:00.000Z" }, "refundedAmount": 5000, "refundedAt": null, "refunds": [ { "amount": 5000, "at": "2026-10-15T00:02:00.000Z", "currency": "KRW", "reason": "고객 요청" } ], "status": "paid", "subscriptionEvent": null, "subscriptionId": null, "withdrawalNoticeAgreedAt": "2026-10-15T00:00:00.000Z" }, "id": "evt_123", "mode": "test", "type": "order.refunded" } ``` 환불이 여러 번이면 건별 이벤트가 올 수 있으므로 누적 금액과 최신 상태는 주문을 다시 조회해요. ### refund.requested 구매자가 환불을 요청하면 보내요. 본문과 data의 mode는 test 또는 live예요. 서명 헤더와 본문 형식은 [웹훅](https://docs.payri.kr/developers/webhooks)에서 확인해요. data는 [환불 요청 객체](https://docs.payri.kr/developers/rest-api/refunds#%ED%99%98%EB%B6%88-%EC%9A%94%EC%B2%AD-%EA%B0%9D%EC%B2%B4)예요. #### 예시 ```json { "createdAt": "2026-10-15T00:00:00.000Z", "data": { "approvedAmount": null, "autoApproveAt": "2026-10-18T00:00:00.000Z", "breakdown": { "orders": [ { "amount": 10000, "currency": "KRW", "orderId": "ord_123" } ] }, "buyerEmail": "buyer@example.com", "buyerReason": "상품을 환불하고 싶어요.", "createdAt": "2026-10-15T00:00:00.000Z", "currency": "KRW", "decidedAt": null, "decidedBy": null, "hold": null, "id": "rfr_123", "kind": "order", "mode": "test", "orderId": "ord_123", "productId": "prod_123", "refundableAmount": 10000, "rejectReason": null, "requestedAmount": 10000, "status": "requested", "statutory": true, "subscriptionId": null, "updatedAt": "2026-10-15T00:00:00.000Z", "warnings": [] }, "id": "evt_123", "mode": "test", "type": "refund.requested" } ``` ### refund.approved 환불 요청이 승인되면 보내요. 본문과 data의 mode는 test 또는 live예요. 서명 헤더와 본문 형식은 [웹훅](https://docs.payri.kr/developers/webhooks)에서 확인해요. data는 [환불 요청 객체](https://docs.payri.kr/developers/rest-api/refunds#%ED%99%98%EB%B6%88-%EC%9A%94%EC%B2%AD-%EA%B0%9D%EC%B2%B4)예요. #### 예시 ```json { "createdAt": "2026-10-15T00:00:00.000Z", "data": { "approvedAmount": 10000, "autoApproveAt": "2026-10-18T00:00:00.000Z", "breakdown": { "orders": [ { "amount": 10000, "currency": "KRW", "orderId": "ord_123" } ] }, "buyerEmail": "buyer@example.com", "buyerReason": "상품을 환불하고 싶어요.", "createdAt": "2026-10-15T00:00:00.000Z", "currency": "KRW", "decidedAt": "2026-10-15T00:02:00.000Z", "decidedBy": "seller", "hold": null, "id": "rfr_123", "kind": "order", "mode": "test", "orderId": "ord_123", "productId": "prod_123", "refundableAmount": 10000, "rejectReason": null, "requestedAmount": 10000, "status": "approved", "statutory": true, "subscriptionId": null, "updatedAt": "2026-10-15T00:02:00.000Z", "warnings": [] }, "id": "evt_123", "mode": "test", "type": "refund.approved" } ``` 실제 환불 여부는 order.refunded와 주문의 최신 상태를 확인해요. ### refund.rejected 환불 요청이 거절되면 보내요. 본문과 data의 mode는 test 또는 live예요. 서명 헤더와 본문 형식은 [웹훅](https://docs.payri.kr/developers/webhooks)에서 확인해요. data는 [환불 요청 객체](https://docs.payri.kr/developers/rest-api/refunds#%ED%99%98%EB%B6%88-%EC%9A%94%EC%B2%AD-%EA%B0%9D%EC%B2%B4)예요. #### 예시 ```json { "createdAt": "2026-10-15T00:00:00.000Z", "data": { "approvedAmount": null, "autoApproveAt": "2026-10-18T00:00:00.000Z", "breakdown": { "orders": [ { "amount": 10000, "currency": "KRW", "orderId": "ord_123" } ] }, "buyerEmail": "buyer@example.com", "buyerReason": "상품을 환불하고 싶어요.", "createdAt": "2026-10-15T00:00:00.000Z", "currency": "KRW", "decidedAt": "2026-10-15T00:02:00.000Z", "decidedBy": "seller", "hold": null, "id": "rfr_123", "kind": "order", "mode": "test", "orderId": "ord_123", "productId": "prod_123", "refundableAmount": 10000, "rejectReason": "환불 정책에 따라 승인할 수 없어요.", "requestedAmount": 10000, "status": "rejected", "statutory": true, "subscriptionId": null, "updatedAt": "2026-10-15T00:02:00.000Z", "warnings": [] }, "id": "evt_123", "mode": "test", "type": "refund.rejected" } ``` ### refund.on_hold 환불 요청 처리가 보류되면 보내요. 본문과 data의 mode는 test 또는 live예요. 서명 헤더와 본문 형식은 [웹훅](https://docs.payri.kr/developers/webhooks)에서 확인해요. data는 [환불 요청 객체](https://docs.payri.kr/developers/rest-api/refunds#%ED%99%98%EB%B6%88-%EC%9A%94%EC%B2%AD-%EA%B0%9D%EC%B2%B4)예요. #### 예시 ```json { "createdAt": "2026-10-15T00:00:00.000Z", "data": { "approvedAmount": null, "autoApproveAt": null, "breakdown": { "orders": [ { "amount": 10000, "currency": "KRW", "orderId": "ord_123" } ] }, "buyerEmail": "buyer@example.com", "buyerReason": "상품을 환불하고 싶어요.", "createdAt": "2026-10-15T00:00:00.000Z", "currency": "KRW", "decidedAt": "2026-10-15T00:01:00.000Z", "decidedBy": "seller", "hold": { "heldAt": "2026-10-15T00:01:00.000Z", "reason": "추가 확인 중이에요.", "type": "in_review" }, "id": "rfr_123", "kind": "order", "mode": "test", "orderId": "ord_123", "productId": "prod_123", "refundableAmount": 10000, "rejectReason": null, "requestedAmount": 10000, "status": "on_hold", "statutory": true, "subscriptionId": null, "updatedAt": "2026-10-15T00:01:00.000Z", "warnings": [] }, "id": "evt_123", "mode": "test", "type": "refund.on_hold" } ``` ### refund.withdrawn 구매자가 환불 요청을 철회하면 보내요. 본문과 data의 mode는 test 또는 live예요. 서명 헤더와 본문 형식은 [웹훅](https://docs.payri.kr/developers/webhooks)에서 확인해요. data는 [환불 요청 객체](https://docs.payri.kr/developers/rest-api/refunds#%ED%99%98%EB%B6%88-%EC%9A%94%EC%B2%AD-%EA%B0%9D%EC%B2%B4)예요. #### 예시 ```json { "createdAt": "2026-10-15T00:00:00.000Z", "data": { "approvedAmount": null, "autoApproveAt": "2026-10-18T00:00:00.000Z", "breakdown": { "orders": [ { "amount": 10000, "currency": "KRW", "orderId": "ord_123" } ] }, "buyerEmail": "buyer@example.com", "buyerReason": "상품을 환불하고 싶어요.", "createdAt": "2026-10-15T00:00:00.000Z", "currency": "KRW", "decidedAt": "2026-10-15T00:02:00.000Z", "decidedBy": null, "hold": null, "id": "rfr_123", "kind": "order", "mode": "test", "orderId": "ord_123", "productId": "prod_123", "refundableAmount": 10000, "rejectReason": null, "requestedAmount": 10000, "status": "withdrawn", "statutory": true, "subscriptionId": null, "updatedAt": "2026-10-15T00:02:00.000Z", "warnings": [] }, "id": "evt_123", "mode": "test", "type": "refund.withdrawn" } ``` refund.withdrawn로 철회된 요청은 승인·거절하지 않아요. ## 라이선스 발급 이벤트 [재시도·멱등 처리와 서명 검증](https://docs.payri.kr/developers/licensing/issue-webhook)을 참고해요. ## 테스트 이벤트 콘솔에서 테스트 발송을 요청하면 실제 이벤트와 구분되는 테스트 이벤트를 보내요. 본문과 data의 mode는 test 또는 live예요. 서명 헤더와 본문 형식은 [웹훅](https://docs.payri.kr/developers/webhooks)에서 확인해요. #### data 필드 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `endpointId` | `string` | 예 | 테스트 이벤트를 보낸 엔드포인트 식별자예요. | | `wh_123` | | `mode` | `string` | 예 | 이벤트의 결제 환경이에요. | `live` · `test` | | | `sentAt` | `string` | 예 | 테스트 이벤트가 발송된 시각이에요. | | `2026-10-15T00:00:00.000Z` | #### 예시 ```json { "createdAt": "2026-10-15T00:00:00.000Z", "data": { "endpointId": "wh_123", "mode": "test", "sentAt": "2026-10-15T00:00:00.000Z" }, "id": "evt_123", "mode": "test", "type": "webhook.test" } ``` --- Source: https://docs.payri.kr/developers/webhooks/signature # 서명 검증 ## 서명 규칙 | 항목 | 값 | |---|---| | 서명 대상 | webhook-id + "." + webhook-timestamp + "." + rawBody | | 알고리즘 | HMAC-SHA256 | | 키 | 엔드포인트의 whsec_ 뒤 문자열을 base64 디코드한 바이트예요. | | 헤더 형식 | webhook-signature에는 v1,<sig> 형식의 서명을 담아요. | | 키 회전 | 24시간 유예 동안 v1,<새 서명> v1,<이전 서명>처럼 새 키와 이전 키의 서명 두 개가 올 수 있어요. 어느 한 서명이라도 보유한 키와 일치하면 통과예요. | | 시각 검증 | webhook-timestamp가 현재 시각에서 5분 이내인지 확인해요. | 콘솔 재전송은 같은 webhook-id와 새 webhook-timestamp를 사용하며, 새 발송 시각으로 서명을 다시 계산해요. > [!WARNING] > JSON 파싱·재직렬화 전에 원문 바이트를 보관해 서명을 검증해요. ## Node.js 패키지 사용 ```js import { Webhook } from 'standardwebhooks' const webhook = new Webhook(process.env.PAYRI_WEBHOOK_SECRET) // whsec_... const event = webhook.verify(req.rawBody, { 'webhook-id': req.get('webhook-id'), 'webhook-timestamp': req.get('webhook-timestamp'), 'webhook-signature': req.get('webhook-signature') }) ``` ## Node.js 직접 구현 ```js import express from 'express' import crypto from 'node:crypto' const app = express() function verifyWebhook({ id, timestamp, signatureHeader, rawBody, secrets }) { const sentAt = Number(timestamp) if (!Number.isSafeInteger(sentAt) || Math.abs(Math.floor(Date.now() / 1000) - sentAt) > 300) { return false } const signedBytes = Buffer.concat([ Buffer.from(id + '.' + timestamp + '.'), rawBody ]) const candidates = (signatureHeader ?? '').trim().split(/\s+/).filter(Boolean) for (const item of candidates) { const comma = item.indexOf(',') if (comma < 0 || item.slice(0, comma) !== 'v1') continue const supplied = Buffer.from(item.slice(comma + 1), 'base64') for (const secret of secrets.filter(Boolean)) { if (!secret.startsWith('whsec_')) continue const key = Buffer.from(secret.slice('whsec_'.length), 'base64') const expected = crypto.createHmac('sha256', key).update(signedBytes).digest() if (supplied.length === expected.length && crypto.timingSafeEqual(supplied, expected)) { return true } } } return false } // Express는 JSON 파싱 전에 원문 바이트를 보관해요. app.use(express.json({ verify: (req, _res, buf) => { req.rawBody = Buffer.from(buf) } })) app.post('/webhook', (req, res) => { const valid = verifyWebhook({ id: req.get('webhook-id') ?? '', timestamp: req.get('webhook-timestamp') ?? '', signatureHeader: req.get('webhook-signature') ?? '', rawBody: req.rawBody, secrets: [ process.env.PAYRI_WEBHOOK_SECRET, process.env.PAYRI_WEBHOOK_SECRET_PREVIOUS ] }) if (!valid) return res.sendStatus(400) // webhook-id를 저장해 중복 이벤트를 걸러낸 뒤 처리해요. return res.sendStatus(202) }) ``` --- Source: https://docs.payri.kr/developers/webhooks/retry # 재시도와 재전송 ![웹훅은 2xx를 받을 때까지 최대 8번 보내요. 첫 발송 뒤 실패하면 1분, 5분, 30분, 2시간, 6시간, 12시간, 24시간 간격으로 다시 보내고, 모두 실패하면 약 45시간 뒤 최종 실패로 끝나 셀러에게 메일을 보내요.](https://docs.payri.kr/image/developers/webhook-retry.png) _실패하면 간격을 늘려 다시 보내요_ ## 재시도 일정 최초 발송을 포함해 최대 8회 전송하며, 모두 실패하면 약 45시간 뒤 최종 실패로 끝나고 셀러에게 이메일을 보내요. | 시도 | 직전 실패 후 대기 | |---|---| | 1회차 | 즉시 | | 2회차 | 1분 | | 3회차 | 5분 | | 4회차 | 30분 | | 5회차 | 2시간 | | 6회차 | 6시간 | | 7회차 | 12시간 | | 8회차 | 24시간 | ## 발송 이력과 다시 보내기 - 셀러 콘솔의 외부 연동에서 엔드포인트별 발송 이력(요청 본문·응답 코드·응답 본문 앞 1KB)을 확인하고 재전송해요. - 재전송 API는 없으며, 같은 webhook-id와 새 webhook-timestamp를 사용해 서명을 다시 계산해요. ## 멱등 처리 ```js const eventId = req.get('webhook-id') if (await store.hasProcessed(eventId)) { return res.sendStatus(202) } await store.markProcessed(eventId) // 오래 걸리는 업무는 큐에 넣고 빠르게 응답해요. await queue.publish({ event: req.body, eventId }) return res.sendStatus(202) ``` > [!INFO] > [웹훅 개요의 응답·처리 규칙](https://docs.payri.kr/developers/webhooks#응답과-처리)에 따라 webhook-id로 멱등 처리하고, 같은 결제·환불의 별도 이벤트는 이벤트 종류와 리소스 ID도 확인해요. --- Source: https://docs.payri.kr/developers/licensing # 라이선스 상품 전달물이 라이선스 키면 상품 설정에서 발급 방식을 선택해요. 키 목록(pool)·공용 키(shared)는 셀러 콘솔의 외부 연동에 등록해요. 셀러 서버 발급 키와 pool/shared 값은 셀러가 정한 형식 그대로 사용해요. ![자동 발급은 페이리가, 내 서버 발급은 license.issue 요청에 내 서버가 응답한 키를, 키 목록은 미리 올린 키 하나를 써요. 이 세 방식의 키는 앱 연동 API로 확인할 수 있어요. 공용 키는 같은 키를 모두에게 주고 앱 확인 대상이 아니며(not_found), 직접 전달은 판매자가 따로 전달해 페이리 키가 없어요.](https://docs.payri.kr/image/developers/license-methods.png) _발급 방식에 따라 앱에서 확인할 수 있는지가 달라요_ ## 발급 방식 | 식별자 | 방식 | 동작 | |---|---|---| | payri | PayRi 자동 발급 | PayRi가 키를 만들고 라이선스 상태를 관리해요. 기본값이며 별도 서버가 필요 없어요. | | seller_server | 내 서버에서 발급 | 결제 확정 직후 license.issue 요청을 받고 셀러 서버가 발급한 키를 응답해요. [요청·응답](https://docs.payri.kr/developers/licensing/issue-webhook) | | pool | 키 목록 | 외부 연동에 미리 등록한 키 목록에서 결제마다 키 하나를 소진해요. | | shared | 공용 키 | 외부 연동에 등록한 같은 키를 구매자에게 전달해요. | | manual | 직접 전달 | 라이선스 키를 PayRi에서 발급하지 않아요. 판매자가 별도로 전달해요. | ## 키 형식 | 환경 | 자동 발급 키 형식 | |---|---| | live | PAYRI-XXXXX-XXXXX-XXXXX-XXXXX | | test | PAYRI-TEST-XXXXX-XXXXX-XXXXX-XXXXX | 키 목록·공용 키 상품도 Test 주문에서는 PAYRI-TEST-로 시작하는 테스트 키를 사용해요. 내 서버 발급은 Test license.issue 엔드포인트로 요청해요. ## 앱 연동 API 검증 대상 공용 키는 구매자별 라이선스 검증 대상이 아니므로 not_found와 같은 결과를 반환해요. | 발급 방식 | 앱 연동 API 검증 대상 | |---|---| | PayRi 자동 발급 | 예 | | 내 서버에서 발급 | 예 | | 키 목록(pool) | 예 | | 공용 키(shared) | 아니요 | | 직접 전달 | 해당 없음 — PayRi 라이선스 키를 발급하지 않아요. | 앱의 키 검증·활성화·비활성화에는 [앱 연동 API](https://docs.payri.kr/developers/licensing/app-api)를, 서버의 구매자별 라이선스 조회에는 [Licenses API](https://docs.payri.kr/developers/rest-api/licenses)를 사용해요. --- Source: https://docs.payri.kr/developers/licensing/app-api # 앱 연동 API 일반 /v1 API는 서버 전용이며 CORS를 허용하지 않아요. 앱의 verify, activate, deactivate는 브라우저 요청을 허용하고 라이선스 키로 인증해요. #### 엔드포인트 - [POST /v1/licenses/verify](https://docs.payri.kr/developers/licensing/app-api#라이선스-검증) - [POST /v1/licenses/activate](https://docs.payri.kr/developers/licensing/app-api#라이선스-활성화) - [POST /v1/licenses/deactivate](https://docs.payri.kr/developers/licensing/app-api#라이선스-비활성화) - [GET /v1/licenses/signing-keys](https://docs.payri.kr/developers/licensing/app-api#서명-공개키-조회) ## 엔드포인트 ### 라이선스 검증 **POST https://api.payri.kr/v1/licenses/verify** 상품과 라이선스 키의 상태를 검증하고 nonce와 라이선스 mode가 포함된 응답에 Ed25519 서명을 붙여요. API 키 인증이 필요하지 않아요. #### 요청 본문 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `activationId` | `string` | 아니요 | 상태를 확인할 활성화 식별자예요. | | `act_123` | | `key` | `string` | 예 | 검증할 라이선스 키예요. | | `PAYRI-ABCDE-FGHJK-MNPQR-STUVW` | | `nonce` | `string` | 예 | 응답 위조와 재전송을 확인할 nonce예요. hex 또는 base64로 보내고, 디코딩한 값은 16바이트 이상이어야 해요. 32바이트 hex를 권장해요. | 최소 22자 · 패턴 `^(?:(?:[A-Fa-f0-9]{2}){16,}\|[A-Za-z0-9+/_-]{22,}={0,2})$` | `0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef` | | `productId` | `string` | 예 | 라이선스가 연결된 상품 식별자예요. | | `prod_123` | #### 요청 예시 ```bash curl -X POST https://api.payri.kr/v1/licenses/verify \ -H "Content-Type: application/json" \ -d '{ "key": "PAYRI-TEST-ABCDE-FGHJK-MNPQR-STUVW", "nonce": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "productId": "prod_123", "activationId": "act_123" }' ``` 응답: HTTP 200, [라이선스 앱 응답](https://docs.payri.kr/developers/licensing/app-api#라이선스-앱-응답) #### 성공 응답 예시 ```json { "activation": { "activatedAt": "2026-10-15T00:02:00.000Z", "deactivatedAt": null, "id": "act_123", "label": "내 컴퓨터", "lastVerifiedAt": "2026-10-15T00:02:30.000Z" }, "activationCount": 1, "expiresAt": null, "issuedAt": "2026-10-15T00:02:30.000Z", "maxActivations": null, "mode": "test", "nonce": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "status": "active" } ``` #### 오류 코드 | 코드 | HTTP | 의미 | | --- | ---: | --- | | `LICENSE_RATE_LIMITED` | 429 | 라이선스 API 호출 한도를 넘었어요. Retry-After 이후 다시 시도해 주세요. | | `LICENSE_LOOKUP_BLOCKED` | 429 | 라이선스 조회 제한에 걸려 요청할 수 없어요. Retry-After 이후 다시 시도해 주세요. | 표에 없는 공통 오류(입력 검증, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. ### 라이선스 활성화 **POST https://api.payri.kr/v1/licenses/activate** 서버가 새 활성화 식별자를 발급하고 mode가 포함된 서명된 라이선스 상태를 돌려줘요. 활성화 식별자는 요청에서 지정하지 않아요. API 키 인증이 필요하지 않아요. #### 요청 본문 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `key` | `string` | 예 | 활성화할 라이선스 키예요. | | `PAYRI-ABCDE-FGHJK-MNPQR-STUVW` | | `label` | `string` | 아니요 | 기기 이름이에요. | | `내 컴퓨터` | | `nonce` | `string` | 예 | 응답 위조와 재전송을 확인할 nonce예요. hex 또는 base64로 보내고, 디코딩한 값은 16바이트 이상이어야 해요. 32바이트 hex를 권장해요. | 최소 22자 · 패턴 `^(?:(?:[A-Fa-f0-9]{2}){16,}\|[A-Za-z0-9+/_-]{22,}={0,2})$` | `0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef` | | `productId` | `string` | 예 | 라이선스가 연결된 상품 식별자예요. | | `prod_123` | #### 요청 예시 ```bash curl -X POST https://api.payri.kr/v1/licenses/activate \ -H "Content-Type: application/json" \ -d '{ "key": "PAYRI-TEST-ABCDE-FGHJK-MNPQR-STUVW", "nonce": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "productId": "prod_123", "label": "내 컴퓨터" }' ``` 응답: HTTP 200, [라이선스 앱 응답](https://docs.payri.kr/developers/licensing/app-api#라이선스-앱-응답) #### 성공 응답 예시 ```json { "activation": { "activatedAt": "2026-10-15T00:02:00.000Z", "deactivatedAt": null, "id": "act_123", "label": "내 컴퓨터", "lastVerifiedAt": "2026-10-15T00:02:30.000Z" }, "activationCount": 1, "expiresAt": null, "issuedAt": "2026-10-15T00:02:30.000Z", "maxActivations": null, "mode": "test", "nonce": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "status": "active" } ``` #### 오류 코드 | 코드 | HTTP | 의미 | | --- | ---: | --- | | `LICENSE_RATE_LIMITED` | 429 | 라이선스 API 호출 한도를 넘었어요. Retry-After 이후 다시 시도해 주세요. | | `LICENSE_LOOKUP_BLOCKED` | 429 | 라이선스 조회 제한에 걸려 요청할 수 없어요. Retry-After 이후 다시 시도해 주세요. | 표에 없는 공통 오류(입력 검증, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. ### 라이선스 비활성화 **POST https://api.payri.kr/v1/licenses/deactivate** 라이선스 키와 활성화 식별자로 한 기기의 활성화를 해제하고 mode가 포함된 서명된 상태를 돌려줘요. API 키 인증이 필요하지 않아요. #### 요청 본문 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `activationId` | `string` | 예 | 해제할 활성화 식별자예요. | | `act_123` | | `key` | `string` | 예 | 비활성화할 라이선스 키예요. | | `PAYRI-ABCDE-FGHJK-MNPQR-STUVW` | | `nonce` | `string` | 예 | 응답 위조와 재전송을 확인할 nonce예요. hex 또는 base64로 보내고, 디코딩한 값은 16바이트 이상이어야 해요. 32바이트 hex를 권장해요. | 최소 22자 · 패턴 `^(?:(?:[A-Fa-f0-9]{2}){16,}\|[A-Za-z0-9+/_-]{22,}={0,2})$` | `0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef` | | `productId` | `string` | 예 | 라이선스가 연결된 상품 식별자예요. | | `prod_123` | #### 요청 예시 ```bash curl -X POST https://api.payri.kr/v1/licenses/deactivate \ -H "Content-Type: application/json" \ -d '{ "activationId": "act_123", "key": "PAYRI-TEST-ABCDE-FGHJK-MNPQR-STUVW", "nonce": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "productId": "prod_123" }' ``` 응답: HTTP 200, [라이선스 앱 응답](https://docs.payri.kr/developers/licensing/app-api#라이선스-앱-응답) #### 성공 응답 예시 ```json { "activation": { "activatedAt": "2026-10-15T00:02:00.000Z", "deactivatedAt": "2026-10-15T00:03:00.000Z", "id": "act_123", "label": "내 컴퓨터", "lastVerifiedAt": "2026-10-15T00:02:30.000Z" }, "activationCount": 0, "expiresAt": null, "issuedAt": "2026-10-15T00:03:00.000Z", "maxActivations": null, "mode": "test", "nonce": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", "status": "active" } ``` #### 오류 코드 | 코드 | HTTP | 의미 | | --- | ---: | --- | | `LICENSE_RATE_LIMITED` | 429 | 라이선스 API 호출 한도를 넘었어요. Retry-After 이후 다시 시도해 주세요. | | `LICENSE_LOOKUP_BLOCKED` | 429 | 라이선스 조회 제한에 걸려 요청할 수 없어요. Retry-After 이후 다시 시도해 주세요. | 표에 없는 공통 오류(입력 검증, 서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. ### 서명 공개키 조회 **GET https://api.payri.kr/v1/licenses/signing-keys** 키 식별자별 Ed25519 공개키를 조회해 라이선스 응답 서명을 검증해요. API 키 인증이 필요하지 않아요. #### 요청 예시 ```bash curl https://api.payri.kr/v1/licenses/signing-keys ``` #### 성공 응답 필드 (HTTP 200) | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `keys` | `array of object` | 예 | 라이선스 응답 서명 검증에 사용할 공개키 목록이에요. | | | | `keys[].createdAt` | `string` | 예 | 공개키가 유효해진 시각이에요. | | `2026-10-01T00:00:00.000Z` | | `keys[].kid` | `string` | 예 | 공개키 식별자예요. | | `2026-10` | | `keys[].publicKey` | `string` | 예 | Ed25519 원시 32바이트 공개키의 Base64 값이에요. | | `AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=` | #### 성공 응답 예시 ```json { "keys": [ { "createdAt": "2026-10-01T00:00:00.000Z", "kid": "2026-10", "publicKey": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcYGRobHB0eHyA=" } ] } ``` 표에 없는 공통 오류(서버 오류)는 [에러 코드](https://docs.payri.kr/developers/rest-api/errors)에서 확인해요. ## 라이선스 앱 응답 ### 라이선스 앱 응답 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `activation` | `object \| null` | 예 | 이번 요청과 연결된 활성화 정보예요. | | | | `activation.activatedAt` | `string` | 예 | 활성화가 시작된 시각이에요. | | `2026-10-15T00:00:00.000Z` | | `activation.deactivatedAt` | `string \| null` | 예 | 비활성화 시각이에요. | | `null` | | `activation.id` | `string` | 예 | 서버가 발급한 활성화 식별자예요. | | `act_123` | | `activation.label` | `string \| null` | 예 | 활성화할 기기의 이름이에요. | | `내 컴퓨터` | | `activation.lastVerifiedAt` | `string \| null` | 예 | 마지막 검증 시각이에요. | | `null` | | `activationCount` | `number` | 예 | 현재 활성화 수예요. | | `1` | | `expiresAt` | `string \| null` | 예 | 라이선스 만료 시각이에요. | | `null` | | `issuedAt` | `string` | 예 | 응답을 만든(서명한) 시각이에요. 앱은 이 값이 현재 시각에서 5분 이내인지 확인해 오래된 응답 재사용을 막아요. | | `2026-10-15T00:01:00.000Z` | | `maxActivations` | `number \| null` | 예 | 허용된 최대 활성화 수예요. 무제한이면 null이에요. | | `null` | | `mode` | `string` | 예 | test 또는 live 라이선스 환경을 알려줘요. | `live` · `test` | `live` | | `nonce` | `string` | 예 | 요청 때 보낸 16바이트 이상 nonce를 그대로 돌려줘요. hex 또는 base64로 보내며 32바이트 hex를 권장해요. | | `0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef` | | `status` | `string` | 예 | 라이선스 검증 결과예요. | `active` · `expired` · `revoked` · `pending` · `not_found` | `active` | ## 요청 규칙 | 필드 | verify | activate | deactivate | 규칙 | |---|---|---|---|---| | key | 필수 | 필수 | 필수 | 라이선스 키를 보내요. | | productId | 필수 | 필수 | 필수 | 상품 ID를 보내요. | | nonce | 필수 | 필수 | 필수 | 요청별 응답을 연결하는 nonce예요. | | activationId | 선택 | — | 필수 | 서버가 발급한 ID예요. | | label | — | 선택 | — | 활성화할 기기의 라벨이에요. | nonce 규칙은 다음과 같아요. - 요청마다 예측하기 어려운 새 base64 또는 hex 값을 보내고, 디코딩한 값은 16바이트 이상이어야 해요. - hex는 짝수 길이 32자 이상이어야 해요. - SDK처럼 32바이트 값을 hex 64자로 인코딩하는 형식을 권장해요. - 응답의 nonce가 요청에 보낸 값과 같은지 확인해요. ## 응답 규칙 정상 응답은 HTTP 200이며 형식 오류·호출 제한·서버 오류는 각각 HTTP 400·429·500으로 응답해요. maxActivations: null은 활성화 수 제한이 없고, expiresAt: null은 만료가 없다는 뜻이에요. - verify 요청에 activationId를 지정하면 해당 활성화의 lastVerifiedAt을 갱신한 뒤 그 활성화를 반환해요. - activate 성공 응답에는 서버가 생성한 활성화 ID가 포함돼요. 이 ID를 저장해 deactivate에 보내요. - deactivate에 없는 activationId를 보내면 not_found, 이미 해제된 ID면 처음 해제한 결과가 그대로 와요. - 활성화 한도에 찼거나 라이선스가 active가 아니면 오류 대신 activation: null로 와요. - 기기 사용을 허용하려면 서명·nonce를 확인하고 status가 active인지 봐요. 운영 앱에서는 mode가 live인지도 확인하고, activation이 있으며 activation.deactivatedAt이 null인지 확인해요. 이 API는 mode로 분리되지 않아 PAYRI-TEST-로 시작하는 test 키도 같은 엔드포인트에서 응답해요. | 상태 | 의미 | |---|---| | active | 키가 사용 가능한 상태예요. | | expired | 키의 만료 시각이 지났어요. | | revoked | 셀러 폐기, 전액 환불, 구독 즉시 해지로 키가 폐기됐어요. | | pending | 내 서버의 발급 요청을 처리 중이에요. | | not_found | 일치하는 라이선스가 없어요. | ## not_found와 호출 제한 키가 없거나 다른 상품·스토어에 속하거나 공용 키이면 모두 HTTP 200과 같은 not_found를 반환하며, 404나 구분 상세 정보는 반환하지 않아요. | 기준 | 한도 | 넘으면 | |---|---|---| | IP | 분당 60회 | 429 LICENSE_RATE_LIMITED | | 라이선스 키 | 키당 분당 30회 | 429 LICENSE_RATE_LIMITED | | not_found 차단 | 같은 IP에서 10분 안에 10회 | IP를 15분 동안 차단하고 429 LICENSE_LOOKUP_BLOCKED | 세 경우 모두 Retry-After 헤더에서 대기 시간을 초 단위로 확인해요. HTTP 400 VALIDATION_FAILED와 두 HTTP 429 오류(LICENSE_RATE_LIMITED, LICENSE_LOOKUP_BLOCKED) 응답에는 Payri-Signature가 없어요. ## 응답 서명 검증 verify, activate, deactivate의 HTTP 200 응답은 원문 본문에 Ed25519 서명한 Payri-Signature: kid=<kid>, ed25519=<base64>를 포함하므로 파싱 전에 원문 바이트를 보관해요. signing-keys 응답은 서명하지 않아요. 1. Payri-Signature에서 kid와 base64 서명을 파싱해요. 2. 앱에 고정한 해당 공개 키로 응답 원문을 Ed25519 검증하고, 본문의 nonce가 요청값과 같은지, issuedAt이 현재 시각에서 5분 이내인지 확인해요. 서명, nonce, 시각 중 하나라도 검증에 실패하면 응답을 신뢰하지 마세요. - 공개 키는 SPKI DER이 아닌 Ed25519 raw 32바이트를 base64로 인코딩하며, SDK 내장 테이블이나 개발자 문서의 값을 앱에 고정해 검증해요. - 고정 키에 없는 kid는 검증 실패로 처리하고, 검증 대상 서버에서 받은 공개 키는 서명 검증에 사용하지 마세요. - 키 교체 확인용 GET /v1/licenses/signing-keys는 API 키 없이 모든 origin의 브라우저 CORS 요청을 허용하고, 캐시 가능한 { keys: [{ kid, publicKey, createdAt }] }를 반환해요. ### 키 교체 1. 다음 kid의 공개 키를 SDK 내장 테이블과 개발자 문서에 현재 키와 함께 먼저 게시해요. 2. SDK 배포와 앱 업데이트가 퍼질 때까지 수개월 기다려요. 3. 앱 업데이트 보급 상황을 기준으로 시점을 판단해 서버 서명을 새 kid로 전환해요. 서버 서명을 새 kid로 바꾸면 새 공개 키가 없는 앱은 즉시 응답 검증에 실패해요. 앱에 게시된 현재·다음 공개 키를 모두 고정하고, SDK 내장 테이블을 쓰면 서버 전환 전에 앱과 SDK를 업데이트해요. ## Node.js 검증 예제 ```js import { createPublicKey, verify } from 'node:crypto' function verifyPayriResponse({ rawBody, signatureHeader, requestNonce, publicKeys }) { const match = /^kid=([^,]+),\s*ed25519=([A-Za-z0-9+/=]+)$/.exec(signatureHeader ?? '') if (!match) throw new Error('Payri-Signature 형식이 올바르지 않아요.') const publicKeyBase64 = publicKeys.get(match[1]) if (!publicKeyBase64) throw new Error('공개 키를 찾을 수 없어요.') const rawPublicKey = Buffer.from(publicKeyBase64, 'base64') if (rawPublicKey.length !== 32) throw new Error('공개 키 길이가 올바르지 않아요.') const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex') const publicKey = createPublicKey({ key: Buffer.concat([spkiPrefix, rawPublicKey]), format: 'der', type: 'spki' }) const signature = Buffer.from(match[2], 'base64') if (!verify(null, rawBody, publicKey, signature)) { throw new Error('응답 서명이 올바르지 않아요.') } const body = JSON.parse(rawBody.toString('utf8')) if (body.nonce !== requestNonce) throw new Error('nonce가 일치하지 않아요.') const issuedAt = Date.parse(body.issuedAt) if (!Number.isFinite(issuedAt) || Math.abs(Date.now() - issuedAt) > 5 * 60 * 1000) { throw new Error('응답 발급 시각이 허용 범위를 벗어났어요.') } return body } ``` ## Web Crypto 검증 예제 ```js async function verifyPayriResponse({ response, requestNonce, publicKeys }) { const header = response.headers.get('Payri-Signature') ?? '' const match = /^kid=([^,]+),\s*ed25519=([A-Za-z0-9+/=]+)$/.exec(header) if (!match) throw new Error('Payri-Signature 형식이 올바르지 않아요.') const publicKeyBase64 = publicKeys.get(match[1]) if (!publicKeyBase64) throw new Error('공개 키를 찾을 수 없어요.') const keyBytes = Uint8Array.from(atob(publicKeyBase64), char => char.charCodeAt(0)) const publicKey = await crypto.subtle.importKey( 'raw', keyBytes, { name: 'Ed25519' }, false, ['verify'] ) const rawBody = new Uint8Array(await response.arrayBuffer()) const signature = Uint8Array.from(atob(match[2]), char => char.charCodeAt(0)) const valid = await crypto.subtle.verify( { name: 'Ed25519' }, publicKey, signature, rawBody ) if (!valid) throw new Error('응답 서명이 올바르지 않아요.') const body = JSON.parse(new TextDecoder().decode(rawBody)) if (body.nonce !== requestNonce) throw new Error('nonce가 일치하지 않아요.') const issuedAt = Date.parse(body.issuedAt) if (!Number.isFinite(issuedAt) || Math.abs(Date.now() - issuedAt) > 5 * 60 * 1000) { throw new Error('응답 발급 시각이 허용 범위를 벗어났어요.') } return body } ``` --- Source: https://docs.payri.kr/developers/licensing/issue-webhook # 내 서버에서 발급 셀러 서버가 자체 키를 발급하면 license.issue 웹훅으로 결제 직후 동기 요청을 받아요. 엔드포인트 등록 범위와 충돌 응답은 [웹훅 개요](https://docs.payri.kr/developers/webhooks)를 참고해요. 엔드포인트를 등록하지 않으면 라이선스가 pending으로 남고 셀러 콘솔에 경고가 표시돼요. ## 동작 흐름 ```mermaid %%{init:{'theme':'base','themeVariables':{'primaryColor':'#eef2ff','primaryBorderColor':'#2e5bff','primaryTextColor':'#1e3a8a','lineColor':'#7c93ff','fontFamily':'inherit','textColor':'#1e3a8a','edgeLabelBackground':'#eef2ff','clusterBkg':'#f5f8ff','clusterBorder':'#c7d3ff'},'flowchart':{'curve':'basis','padding':16,'nodeSpacing':45,'rankSpacing':55}}}%% flowchart TD A["결제 확정 커밋"] --> B["license.issue 동기 요청"] B --> C{"유효한 2xx 응답과 키"} C -->|성공| D["응답 키 저장"] C -->|실패·타임아웃| E["pending 유지"] E --> F["재시도 일정에 따라 다시 요청"] F --> B E --> G["결제 완료 화면: 발급 중"] classDef brand fill:#2e5bff,stroke:#1e40af,color:#fff; classDef node fill:#ffffff,stroke:#2e5bff,color:#1e3a8a; classDef ask fill:#f5f8ff,stroke:#93b4ff,color:#1e3a8a; classDef bad fill:#fef2f2,stroke:#ef4444,color:#991b1b; classDef ok fill:#ecfdf5,stroke:#10b981,color:#065f46; class A brand; class B,F node; class C ask; class E,G bad; class D ok; ``` ## 요청 본문 판매자가 발급한 라이선스 키가 필요한 상품의 결제 뒤 동기 발급을 요청해요. 응답 처리가 5회 모두 실패하면 라이선스 상태를 failed로 바꿔요. 본문과 data의 mode는 test 또는 live예요. 서명 헤더와 본문 형식은 [웹훅](https://docs.payri.kr/developers/webhooks)에서 확인해요. #### data 필드 | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `buyer` | `object` | 예 | 라이선스 구매자 정보예요. | | | | `buyer.email` | `string` | 예 | 구매자 이메일 주소예요. | | `buyer@example.com` | | `buyer.name` | `string` | 예 | 구매자 이름이에요. | | `홍길동` | | `expiresAt` | `string \| null` | 예 | 라이선스 만료 시각이에요. | | `null` | | `interval` | `string \| null` | 예 | 구독 라이선스의 결제 주기예요. | `monthly` · `yearly` · `null` | `null` | | `licenseId` | `string` | 예 | 발급 대기 중인 라이선스 식별자예요. | | `lic_123` | | `maxActivations` | `number \| null` | 예 | 라이선스의 최대 활성화 수예요. 무제한이면 null이에요. | | `null` | | `mode` | `string` | 예 | 이벤트의 결제 환경이에요. | `live` · `test` | | | `orderId` | `string` | 예 | 라이선스 발급을 요청한 주문 식별자예요. | | `ord_123` | | `planId` | `string \| null` | 예 | 구독 라이선스의 플랜 식별자예요. | | `null` | | `productId` | `string` | 예 | 라이선스 발급 대상 상품 식별자예요. | | `prod_123` | #### 예시 ```json { "createdAt": "2026-10-15T00:00:00.000Z", "data": { "buyer": { "email": "buyer@example.com", "name": "홍길동" }, "expiresAt": null, "interval": null, "licenseId": "lic_123", "maxActivations": null, "mode": "test", "orderId": "ord_123", "planId": null, "productId": "prod_123" }, "id": "evt_123", "mode": "test", "type": "license.issue" } ``` 요청 헤더와 원문 본문 서명 규칙은 [웹훅 서명 검증](https://docs.payri.kr/developers/webhooks/signature)을 참고해요. ## 발급 응답 서버는 2xx와 JSON 응답으로 발급한 키를 돌려줘요. ```json { "key": "seller-license-key-123", "expiresAt": "2027-10-15T03:20:05.000Z", "maxActivations": 3 } ``` ### V1LicenseIssueResponse | 필드 | 타입 | 필수 | 설명 | 허용 값·제약 | 예시 | | --- | --- | --- | --- | --- | --- | | `expiresAt` | `string \| null` | 아니요 | 발급한 키의 만료 시각이에요. 생략하거나 null이면 요청 data.expiresAt을 그대로 써요. 구독 라이선스는 갱신 때 주기 말로 다시 연장돼요. | ISO 8601 날짜시간 | `2027-10-15T00:00:00.000Z` | | `key` | `string` | 예 | 발급한 라이선스 키예요. 1자 이상 256자 이하 문자열이어야 해요. 요청 data에 key를 더해 그대로 돌려줘도 돼요. key·expiresAt·maxActivations 외 필드는 무시해요. | 1~256자 | `VENDOR-12345-ABCDE` | | `maxActivations` | `integer \| null` | 아니요 | 발급한 키의 최대 활성화 수예요. null이면 무제한이고, 생략하면 요청 data.maxActivations(상품 설정)를 그대로 써요. 0도 무제한으로 받지만 null을 권장해요. | 0 이상 | `null` | expiresAt·maxActivations를 생략하면 요청 data의 값(상품 설정)을 그대로 써요. maxActivations: null은 무제한이고, expiresAt: null은 생략과 같아요. | 실패 | 다시 요청하는 때 | failed가 되는 때 | |---|---|---| | 2xx가 아니거나 10초 안에 응답이 없거나 네트워크 오류가 나거나 응답 본문이 64KB를 넘음 | [웹훅 재시도 일정](https://docs.payri.kr/developers/webhooks/retry)대로 최대 8회 | 8회가 모두 실패할 때 | | 2xx지만 key·expiresAt·maxActivations 검증에 실패 | 1분, 5분, 15분, 60분 뒤 | 발급 시도가 누적 5회가 될 때 | ## 멱등 처리 같은 이벤트 ID인 webhook-id로 다시 요청되면 처음 발급한 키를 그대로 응답해요. 키를 발급하기 전에 서명을 검증하고, 같은 이벤트 ID가 동시에 와도 키를 한 번만 발급하도록 저장소에서 원자적으로 처리해요. ## Node.js 예제 ```js import express from 'express' import { Webhook } from 'standardwebhooks' const app = express() const webhook = new Webhook(process.env.PAYRI_WEBHOOK_SECRET) // 외부 연동에서 발급한 whsec_... app.post('/payri/license-issue', express.raw({ type: 'application/json' }), async (req, res) => { let event try { event = webhook.verify(req.body, { 'webhook-id': req.get('webhook-id'), 'webhook-timestamp': req.get('webhook-timestamp'), 'webhook-signature': req.get('webhook-signature') }) } catch { return res.sendStatus(400) } if (event.type !== 'license.issue') return res.sendStatus(400) try { const issued = await keyStore.getOrCreate( event.id, () => issueLicense(event.data) ) return res.status(200).json({ key: issued.key, ...(issued.expiresAt ? { expiresAt: issued.expiresAt } : {}), ...(issued.maxActivations !== undefined ? { maxActivations: issued.maxActivations } : {}) }) } catch { return res.sendStatus(500) } }) ``` --- Source: https://docs.payri.kr/developers/sdk # SDK PayRi 공식 SDK는 JavaScript와 TypeScript를 지원해요. ## 설치 `@payri/sdk` 1.0.0을 설치해요. ```sh npm install @payri/sdk@1.0.0 ``` ## 구성 요소 | 구성 요소 | 용도 | |---|---| | `Payri` | 서버에서 API 키로 REST API를 호출해요. OpenAPI 기반 요청·응답 타입을 제공하고, 각 리소스는 REST 경로와 1:1로 대응해요. | | `Webhooks` | Standard Webhooks 서명을 검증하고 이벤트 판별 유니온 타입을 반환해요. `license.issue` 응답을 처리하는 핸들러도 제공해요. | | `PayriLicensing` | 앱에서 라이선스 키를 확인하고 활성화해요. API 키 없이 호출하며 nonce 생성과 응답 무결성 검증을 처리해요. | > [!WARNING] > **서버 API 키는 서버에서만 사용해요.** > > 형식과 인증은 [API 키 문서](https://docs.payri.kr/developers/api-keys)에서, 브라우저 연동은 `PayriLicensing`으로 해요. ## 가이드 - [JavaScript](https://docs.payri.kr/developers/sdk/js): 서버 API, 웹훅, 라이선스 앱 연동 예제를 확인해요. --- Source: https://docs.payri.kr/developers/sdk/js # JavaScript SDK ## 서버 클라이언트 `Payri` 서버 클라이언트를 만들고 유예 키 알림을 설정해요. ```ts import { Payri } from '@payri/sdk' const payri = new Payri({ apiKey: process.env.PAYRI_API_KEY!, onKeyDeprecated: (expiresAt) => { console.warn('API 키 교체 만료 시각:', expiresAt) }, }) ``` 기본 `baseUrl`은 `https://api.payri.kr`이며, 별도 지정 시 호스트만 넣고 `/v1`을 붙이지 않으며 앱 라이선스 API를 제외한 POST는 `Idempotency-Key`를 자동 생성하고 GET과 멱등 키가 있는 요청만 재시도해요. 재시도는 같은 키를 쓰고 새 호출에서 재사용하려면 마지막 `options` 인자의 `idempotencyKey`를 지정해요; `onKeyDeprecated`를 지정하면 `Payri-Key-Deprecated` 헤더의 ISO 8601 만료 시각을 `expiresAt`으로 클라이언트당 한 번 전달하고 기본 경고는 출력하지 않아요. > [!WARNING] > **API 키는 서버 환경변수로 보관하고 브라우저 코드에 넣지 마세요.** ## 결제 세션 `checkout.sessions.create()`로 세션을 만들고 구매자를 이동시켜요 ([체크아웃 REST API](https://docs.payri.kr/developers/rest-api/checkout)). ```ts const session = await payri.checkout.sessions.create({ productId: 'prod_123', planId: 'plan_monthly', interval: 'monthly', successUrl: 'https://shop.example.com/thanks', cancelUrl: 'https://shop.example.com/pricing', }) return Response.redirect(session.url) ``` ## 주문 조회와 환불 주문을 조회하고 환불을 요청해요 ([주문 REST API](https://docs.payri.kr/developers/rest-api/orders), [환불 REST API](https://docs.payri.kr/developers/rest-api/refunds)). ```ts const order = await payri.orders.retrieve('ord_123') if (order.status === 'paid') { const refunded = await payri.orders.refund(order.id, { amount: 5000, reason: '고객 요청', }) console.log(refunded.status) } ``` ## 구독 조회와 해지 구독을 조회하고 해지해요 ([구독 REST API](https://docs.payri.kr/developers/rest-api/subscriptions)). ```ts const subscription = await payri.subscriptions.retrieve('sub_123') const result = await payri.subscriptions.cancel(subscription.id, { timing: 'period_end', reason: '고객 요청', }) console.log(result.subscription.status, result.timing, result.refundAmount) ``` ## 환불 요청 처리 환불 요청을 조회하고 승인·거절·보류해요 ([환불 요청 REST API](https://docs.payri.kr/developers/rest-api/refunds)). ```ts const requests = await payri.refundRequests.list({ page: 1, limit: 20 }) const requestId = requests.data[0]?.id const decision: 'approve' | 'reject' | 'hold' = 'approve' if (requestId) { if (decision === 'approve') { await payri.refundRequests.approve(requestId, { amount: 5000 }) } else if (decision === 'reject') { await payri.refundRequests.reject(requestId, { reason: '환불 조건을 충족하지 않아요.' }) } else { await payri.refundRequests.hold(requestId, { type: 'in_review', reason: '증빙을 확인하고 있어요.', }) } } ``` ## 구매자 포털 세션 포털 세션을 만들고 구매자를 이동시켜요 ([포털 세션 REST API](https://docs.payri.kr/developers/rest-api/portal-sessions)). ```ts const portal = await payri.portalSessions.create({ subscriptionId: 'sub_123', returnUrl: 'https://shop.example.com/account', }) return Response.redirect(portal.url) ``` ## 서버에서 라이선스 조회 서버에서 라이선스를 조회해요 ([라이선스 REST API](https://docs.payri.kr/developers/rest-api/licenses)). ```ts const licenses = await payri.licenses.list({ productId: 'prod_123', status: 'active', page: 1, limit: 20, }) const firstLicense = licenses.data[0] if (firstLicense) { const license = await payri.licenses.retrieve(firstLicense.id) console.log(license.buyerEmail, license.status, license.mode) } ``` ## 웹훅 서명 검증과 이벤트 분기 JSON 파싱 전 원본 본문을 `Webhooks.constructEvent()`에 전달해 서명을 검증하고, `event.type`에 따라 `data` 타입을 좁혀요 ([서명 문서](https://docs.payri.kr/developers/webhooks/signature), [이벤트 레퍼런스](https://docs.payri.kr/developers/webhooks/events)). ```ts import express from 'express' import { PayriWebhookError, Webhooks } from '@payri/sdk' const app = express() app.post('/webhooks/payri', express.raw({ type: 'application/json' }), async (req, res) => { let event try { event = await Webhooks.constructEvent({ payload: req.body, headers: req.headers, secret: process.env.PAYRI_WEBHOOK_SECRET!, }) } catch (error) { if (error instanceof PayriWebhookError) return res.sendStatus(400) throw error } if (event.type === 'order.paid') { switch (event.data.subscriptionEvent) { case 'renewal': await recordRenewal(event.data) break case 'proration': await recordProration(event.data) break case 'initial': case null: await fulfillOrder(event.data) break } } res.sendStatus(200) }) ``` ## `license.issue` 처리 `Webhooks.handle()`은 서명과 `license.issue` 응답 형식을 검증해요 ([라이선스 발급 웹훅](https://docs.payri.kr/developers/licensing/issue-webhook)). ```ts import express from 'express' import { Webhooks } from '@payri/sdk' const app = express() app.post('/webhooks/payri', express.raw({ type: 'application/json' }), async (req, res) => { const result = await Webhooks.handle({ payload: req.body, headers: req.headers, secret: process.env.PAYRI_WEBHOOK_SECRET!, onEvent: async (event) => processEvent(event), onLicenseIssue: async (event) => licenseStore.getOrCreate(event.id, () => issueAndSaveKey(event.data)), }) res.status(result.status).set(result.headers).send(result.body) }) ``` ## 라이선스 앱 클라이언트 `PayriLicensing`은 앱 라이선스를 확인·활성화하며 요청마다 32바이트 nonce를 hex 64자로 보내고, 응답 서명·nonce 일치·5분 이내 `issuedAt`을 검증해요. | 메서드 | 인자 | |---|---| | verify({ key, activationId? }) | activationId는 선택이에요. | | activate({ key, label? }) | label은 선택이에요. | | deactivate({ key, activationId }) | activationId를 전달해요. | 라이선스 앱 API의 429는 `PayriRateLimitError`로 전달되며 `code`와 `retryAfterSeconds`에서 `Retry-After` 초를 확인해요 ([라이선스 앱 API](https://docs.payri.kr/developers/licensing/app-api), [응답 서명](https://docs.payri.kr/developers/licensing/app-api#응답-서명-검증)). `publicKeys` 옵션은 내장 공개 키 목록을 확장하고 같은 `kid`가 있으면 옵션 값으로 덮어쓰며, 목록에 없는 `kid`는 검증 오류를 내고 `signing-keys`를 자동 조회하지 않아요. ### 키 교체 키 교체 절차는 [라이선스 앱 API](https://docs.payri.kr/developers/licensing/app-api#키-교체)를 참고해요. ```ts import { PayriLicensing } from '@payri/sdk' const licensing = new PayriLicensing({ productId: 'prod_123', publicKeys: { '<현재 kid>': '', '<다음 kid>': '', }, }) const verified = await licensing.verify({ key: licenseKey }) const activated = await licensing.activate({ key: licenseKey, label: '내 노트북', }) const activationId = activated.activation?.id if (activationId) { await saveActivationId(activationId) await licensing.deactivate({ key: licenseKey, activationId }) } ``` ## 오류 처리 오류 타입은 `PayriApiError`, `PayriRateLimitError`, `PayriWebhookError`, `PayriLicenseSignatureError`로 나뉘며, 서명 오류가 나면 검증 결과를 신뢰하지 않아요 ([API 오류 코드](https://docs.payri.kr/developers/rest-api/errors)). ```ts import { PayriApiError, PayriAuthError, PayriRateLimitError } from '@payri/sdk' try { await payri.orders.retrieve('ord_123') } catch (error) { if (error instanceof PayriAuthError && error.code === 'API_KEY_SCOPE_MISSING') { const details = error.details as { required: string[] } console.error('필요한 권한:', details.required) } else if (error instanceof PayriAuthError && error.code === 'API_KEY_INVALID') { console.error('API 키를 확인해 주세요.') } else if (error instanceof PayriAuthError && error.code === 'API_KEY_IP_NOT_ALLOWED') { console.error('요청 IP 제한을 확인해 주세요.') } else if (error instanceof PayriRateLimitError && error.code === 'RATE_LIMITED') { console.error('재시도 대기 시간(초):', error.retryAfterSeconds) } else if (error instanceof PayriApiError && error.code === 'VALIDATION_FAILED') { const details = error.details as { fields: Array<{ field: string; message: string }> } console.error('입력 오류:', details.fields) } else if (error instanceof PayriApiError && error.code === 'IDEMPOTENCY_KEY_CONFLICT') { console.error('같은 멱등 키에 다른 본문을 보냈어요.') } else if (error instanceof PayriApiError && error.code === 'IDEMPOTENCY_IN_PROGRESS') { console.error('같은 멱등 키 요청이 처리 중이에요.') } else if (error instanceof PayriApiError) { console.error(error.status, error.code, error.details) } else { throw error } } ``` ## 다음 단계 - [웹훅 이벤트](https://docs.payri.kr/developers/webhooks/events): 이벤트 이름과 페이로드 타입을 확인해요. - [라이선스 앱 API](https://docs.payri.kr/developers/licensing/app-api): 앱에서 서명된 라이선스 응답을 검증해요. - [REST API](https://docs.payri.kr/developers/rest-api): SDK가 호출하는 각 엔드포인트를 확인해요. --- Source: https://docs.payri.kr/developers/go-live # 운영 전환 체크리스트 Live mode 배포 전에 아래 항목을 확인해요. ![Live로 전환할 때는 API 키를 pyr_live_ 키로, 웹훅을 Live 엔드포인트와 시크릿으로, 리디렉트를 등록한 운영 도메인으로, 라이선스 발급을 Live license.issue 엔드포인트로 바꿔요. 서명 검증과 멱등 처리는 Test와 똑같이 적용해요.](https://docs.payri.kr/image/developers/go-live.png) _키와 엔드포인트를 Live로 바꿔요_ ## 인증과 키 - [ ] **Live API 키**를 발급해요. ## 웹훅 - [ ] **Live 엔드포인트**를 등록하고 수신 URL·mode·이벤트·상품 범위를 확인해요. - [ ] **원본 본문 서명 검증**을 적용하고 `webhook-id`·`webhook-timestamp`·`webhook-signature`를 확인해 위조 본문을 거절해요. - [ ] **10초 안에 2xx 응답**을 보내기 전 이벤트를 저장하고, 처리할 수 없으면 2xx를 보내지 않아요. - [ ] **이벤트 id 중복 제거**를 적용하고, 순서가 보장되지 않으므로 `createdAt` 오름차순으로 처리하며 이미 처리한 id에는 2xx를 반환해요. ## Checkout과 멱등성 - [ ] **Live 리디렉트 호스트**: `successUrl`, `cancelUrl`, 포털 복귀 URL의 호스트를 등록해요. - [ ] **POST 멱등 키**를 라이선스 앱 API 외 모든 v1 POST에 보내고 재시도에는 같은 키와 본문을 사용해요. - [ ] **복귀 주소**만으로 결제를 확정하지 말고 `order.paid` 서명 또는 서버의 세션·주문 조회로 확인해요. ## 라이선스 - [ ] **`license.issue` 엔드포인트**를 mode·상품 조합마다 하나 등록하고, 같은 이벤트 id에는 같은 키를 반환해요. - [ ] **`license.issue` 서명**을 검증해요. - [ ] **앱 API 응답**의 서명·요청 nonce 일치·5분 이내 `issuedAt`을 검증하고, deactivate에는 서버 발급 활성화 id를 보내요. - [ ] **현재·다음 공개 키**를 앱에 내장하고 SDK 내장 표를 최신으로 유지하며, 다음 키 공지 때 앱을 미리 갱신해요. --- Source: https://docs.payri.kr/developers/changelog # 변경 이력 ## 2026-10 개발자 문서의 외부 연동 계약을 2026-10 기준으로 개편했어요. 실제 반영일은 BE·FE·Docs·SDK 동시 배포일로 기록하고, 날짜는 추후 공지해요. ### 계약 변경과 대체 수단 아래 항목은 새 외부 연동 계약에서 사용할 수 없어요. 대체 기능은 새 경로와 권한을 사용해 연결하세요. | 폐지 항목 | 대체 수단 | |---|---| | 목록 응답 | 목록은 `{ data, total, page, limit }`를 반환해요. `page` 기본값은 1, `limit` 기본값은 20이고 최대 100이에요. `sort`나 `order`를 보내면 400으로 거절돼요. | | 선택으로 보내던 `Idempotency-Key` | 앱 라이선스 API를 제외한 모든 v1 `POST`에 1~255자의 `Idempotency-Key`를 필수로 보내요. 누락하면 `400 VALIDATION_FAILED`예요. | | `pk_live_` · `pk_test_` 형식의 키와 브라우저용 키 | 서버 전용 `pyr_live_…` · `pyr_test_…` API 키를 발급하고 `Authorization: Bearer …`로 인증해요. | | `X-Api-Key` 인증 헤더 | `Authorization: Bearer pyr_live_…` 또는 `Authorization: Bearer pyr_test_…`를 사용해요. | | `linkId` 기반 Checkout Session 본문 | `POST /v1/checkout/sessions`에 `productId`를 보내요. 구독이면 `planId`와 `interval`을 모두 보내요. `interval`은 `monthly` 또는 `yearly`이고, `planId`는 `GET /v1/products`의 `pricing.plans[].id`예요. | | `POST /v1/orders/refund` | `POST /v1/orders/:id/refund`로 주문 id를 경로에 넣어요. [Refunds](https://docs.payri.kr/developers/rest-api/refunds)를 확인해요. | | `POST /v1/subscriptions/cancel` · `POST /v1/subscriptions/cancel-preview` | `POST /v1/subscriptions/:id/cancel`로 해지하고 `GET /v1/subscriptions/:id/cancel-preview`로 미리보기 해요. [Subscriptions](https://docs.payri.kr/developers/rest-api/subscriptions)를 확인해요. | | 본문에 요청 id를 넣는 `POST /v1/refund-requests/approve` · `/reject` · `/hold` | `POST /v1/refund-requests/:id/approve`, `/:id/reject`, `/:id/hold`를 사용해요. [Refunds](https://docs.payri.kr/developers/rest-api/refunds)를 확인해요. | | API 키로 호출하는 라이선스 `verify` · `activate` · `deactivate` | 앱은 라이선스 키로 인증하는 [라이선스 앱 API](https://docs.payri.kr/developers/licensing/app-api)를 호출해요. 서버 조회에는 `licenses:read` 권한을 사용해요. | | `POST /v1/public/orders/:orderId/license` | 앱의 라이선스 키 확인은 라이선스 앱 API를 사용하고, 구매자 전달은 상품의 라이선스 발급 방식을 설정해요. | | 라이선스 전용 시크릿과 외부 연동 통합 시크릿 | REST API에는 `pyr_…` API 키를, 웹훅에는 엔드포인트별 `whsec_…` 시크릿을 사용해요. 앱 라이선스 API는 라이선스 키로 인증해요. | | `orders:write` · `licenses:write` 권한 | 주문 조회는 `orders:read`, 환불 작업은 `refunds:read` · `refunds:write`를 사용해요. 앱 라이선스 API는 라이선스 키로 호출하고, 내 서버 발급은 `license.issue` 웹훅으로 처리해요. | | `subscription.renewed` · `subscription.canceled` 이벤트 | `subscription.renewed` 대체: 갱신 결제 성공은 `order.paid`의 `data.subscriptionEvent=renewal`로, 기간 갱신은 `subscription.updated`의 `data.change.kind=period_renewed`로 확인해요. `subscription.canceled` 대체: 해지 예약은 `subscription.updated`의 `data.change.kind=cancel_scheduled`로, 실제 종료는 `subscription.ended`의 `endReason`으로 확인해요. | | `/developers/guide` · `/developers/api-keys/create` · `/developers/rest-api/authentication` · `/developers/licensing/integration-types` · `/developers/licensing/verify-api` 문서 경로 | 흐름은 [퀵스타트](https://docs.payri.kr/developers/quickstart), 인증은 [인증과 API 키](https://docs.payri.kr/developers/api-keys), 앱 검증은 [라이선스 앱 API](https://docs.payri.kr/developers/licensing/app-api), 서버 발급은 [내 서버에서 발급](https://docs.payri.kr/developers/licensing/issue-webhook)으로 옮겼어요. | ### API 키 계약 확정 - API 키 접두어 뒤는 base62 38자이며 본문 32자와 체크섬 6자로 구성돼요. 예: `pyr_test_0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZab`. - `401 API_KEY_INVALID`은 인증 헤더 누락·형식 오류, 체크섬 불일치, 미등록 키, 회전 유예 만료를 구분하지 않고 같은 코드로 응답해요. - `403 API_KEY_IP_NOT_ALLOWED`는 허용되지 않은 IP를, `403 API_KEY_SCOPE_MISSING`은 부족한 권한을 나타내요. 후자의 `details.required`는 필요한 권한 문자열 배열이에요. - API 키별 한도는 분당 300회예요. 초과하면 `429 RATE_LIMITED`와 초 단위 `Retry-After` 헤더를 반환해요. - 키 회전은 기본 24시간 유예이며 즉시 폐기도 선택할 수 있어요. 유예 중인 옛 키 응답의 `Payri-Key-Deprecated` 값은 만료 시각 ISO 8601이에요. - API 키 인증이 필요한 `/v1` REST API는 서버 전용이에요. 브라우저에서 호출할 수 있는 API는 키 없는 라이선스 앱 API의 `verify`, `activate`, `deactivate`뿐이에요. ### 문서 정정 - SDK: npm `@payri/sdk` 1.0.0을 제공하고, `@payri/nest`는 폐기(deprecate)해요. - `order.paid`는 최초 결제뿐 아니라 갱신 결제와 차액 결제를 포함한 모든 결제 성공 이벤트예요. 구독과 관련한 결제 종류는 `data.subscriptionEvent`로 구분해요. - API 경로 표기에서 중복된 `/v1/v1`을 제거했어요. Base URL은 `https://api.payri.kr`이고 경로는 `/v1/…`예요. - 웹훅 성공 응답 기준을 `200`에서 모든 `2xx`로 바로잡았어요. - 상품 응답에 `refundPolicy.refundDays` 필드를 추가했어요. - 라이선스 앱 API 응답은 `mode`를 포함하고, Test·Live 키가 같은 엔드포인트를 사용해요. - 웹훅 수신 URL은 공개 `http`·`https` 주소만 등록하고 내부·특수 주소에는 보내지 않아요.