판매자 안내

판매자 연동 가이드

내 사이트에서 페이드림에 올린 내 상품을 팔 수 있어요. 결제는 페이드림 화면에서 끝나고, 결과는 서명된 웹훅으로 내 서버에 알려드려요.

연동 방법

세 가지 방법이 있어요. 어느 쪽이든 결제는 paydream.io 주문서에서 끝나고(임베드는 그 주문서를 내 사이트 위의 작은 창으로 열어요), 결과는 웹훅으로 받아요.

방법이럴 때 좋아요필요한 것
결제창 링크내 사이트에 상품 소개가 이미 있고, 구매 버튼만 붙이면 될 때결제창으로 등록한 상품, 버튼 하나
주문서 API내 서버가 상품·옵션·돌아올 주소를 정해 주문서를 열어야 할 때API 키, 내 서버
임베드구매자가 내 사이트를 떠나지 않고 결제하게 하고 싶을 때(주문서 API 위에 스크립트 한 줄)주문서 API + embed.js
국내 카드 결제는 지금 드림시 상품만 열려 있어요(해외 결제는 공급 계약 판매자 상품에 열려요) — 다른 판매자 상품의 결제는 결제대행사 심사가 끝나면 열리고, 그 전에는 테스트 키로 연동을 시험할 수 있어요. 상품의 내용과 제공 책임은 판매자가 져요. 구매자는 회원으로 로그인하거나 휴대폰 문자 인증만으로 비회원 결제를 할 수 있어요.
결제가 끝났는지는 서명을 확인한 웹훅으로만 판단하세요. 구매자가 내 사이트로 돌아왔다는 사실(이동 페이지 도착, return_url 방문)만 보고 권한을 열면 안 돼요. 웹훅을 놓쳤다면 주문 조회 API로 확인해요.

결제창 링크

상품을 등록할 때 결제창을 고르면 결제만 받는 짧은 화면이 만들어져요. 옵션은 1개이고, 화면 테마(라이트·다크), 레이아웃(카드형·라인형), 결제 버튼 색과 문구(12자까지)를 정할 수 있어요. 상품 관리에서 링크를 복사해 내 사이트 버튼에 붙이면 끝이에요.

https://paydream.io/payment/<상품 주소>
https://paydream.io/payment/<상품 주소>?ref=<내 참조값>

<!-- 내 사이트 버튼 예시 -->
<a href="https://paydream.io/payment/notion-template?ref=ord_9f1c2e7a4b6d" target="_blank">구매하기</a>

ref — 내 참조값

링크 뒤에 ?ref=로 내 쪽 참조값을 붙이면, 그 결제의 웹훅 sellerReference에 같은 값이 실려요. 주문과 내 회원·주문을 잇는 데 써요.

  • 쓸 수 있는 문자: 영문 대소문자, 숫자, - _ . : = ~ · 길이 1~128자 · 검사식 ^[A-Za-z0-9\-_.:=~]{1,128}$
  • 규칙에 맞지 않으면 오류 없이 값 전체를 버려요. 잘라서 저장하지 않고, 결제는 ref 없이 그대로 진행돼요. 다른 문자가 필요하면 base64url로 바꿔서 넣으세요.
  • ref는 구매자 주소창에 보이고 구매자가 바꿀 수도 있어요. 회원 번호·이메일·전화번호 대신 추측할 수 없는 값(UUID, 무작위 토큰)을 쓰고, 그 값이 무엇을 뜻하는지는 내 서버에 보관하세요.
  • 정기결제는 첫 결제 때 받은 ref가 이후 모든 회차 결제, 결제 실패, 해지 웹훅에 똑같이 실려요.

진입 페이지와 이동 페이지

  • 진입 페이지(필수) — 결제창을 붙이는 내 사이트 주소예요. 구매자가 결제창에서 돌아갈 곳이고, 페이드림이 상품을 확인할 때도 봐요.
  • 이동 페이지(선택) — 결제를 마친 구매자에게 보여 줄 버튼의 이동 주소예요. 버튼 문구(12자까지)도 함께 정해요. 이동 페이지가 없으면 완료 화면에 주문 조회 버튼이 보여요.
  • 검수 정보(필수, 50~1,000자) — 무엇을, 어떻게·언제 제공하고, 환불은 어떻게 하는지 적어 주세요. 구매자에게는 보이지 않아요.

진입·이동 페이지 주소는 다음을 지켜야 저장돼요.

받지 않는 주소예
http://나 https://로 시작하지 않는 주소my-store.com/product
아이디·비밀번호가 들어간 주소https://user:pass@my-store.com
IP 주소https://203.0.113.5/
도메인에 점이 없거나, 최상위 도메인이 영문 2자 이상이 아닌 주소 (퓨니코드 xn--는 허용)https://localhost
파일 확장자(.pdf .zip .hwp .hwpx .doc .docx .ppt .pptx .xls .xlsx)로 끝나는 주소https://my-store.com/guide.pdf
포트 번호가 들어간 주소https://my-store.com:8080/

API 키

판매 관리 > 연동에서 API 키를 만들어요. 테스트 키(pd_test_로 시작)와 라이브 키(pd_live_로 시작)를 따로 만들 수 있고, 키는 만들 때 한 번만 보여요. 연동을 만드는 동안은 테스트 키를, 운영에서는 라이브 키를 쓰세요. 모든 요청 머리글에 넣어요.

Authorization: Bearer pd_live_xxxxxxxxxxxxxxxxxxxxxxxx
  • API는 내 서버에서만 부르세요. 브라우저나 앱 코드에 키를 넣으면 누구나 볼 수 있어요.
  • 키가 새면 연동 설정에서 바로 새로 만드세요. 같은 종류의 이전 키는 그 즉시 막혀요.
  • 테스트 키와 라이브 키는 같은 내 상품·주문에 접근해요. 실제로 돈이 오가는지는 키가 아니라 페이드림 결제 환경(테스트)이 정해요.
  • 판매자마다 1분에 120번까지 부를 수 있어요. 넘으면 429 rate_limited예요.

주문서 API

내 서버가 주문서 링크를 만들고, 구매자를 그 링크로 보내요.

POST https://paydream.io/api/v1/checkout-sessions
Authorization: Bearer pd_live_xxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json

{
  "product": "notion-template",
  "option": "opt_2Kx9",
  "ref": "ord_9f1c2e7a4b6d",
  "return_url": "https://my.site/thanks"
}
필드설명
product (필수)내 상품의 id 또는 상품 주소(slug). 판매 중인 내 상품만 돼요.
option (선택)그 상품의 옵션 id(상품 목록의 options[].id). 넘기면 그 옵션으로 주문서를 열어요.
ref (선택)내 참조값. 결제창 링크의 ref와 같은 검사식(^[A-Za-z0-9\-_.:=~]{1,128}$, 앞뒤 공백은 지워요)이에요. API는 서버끼리 부르는 것이라 규칙에 맞지 않으면 버리지 않고 400 invalid_ref로 알려 줘요(결제창 링크는 조용히 버려요).
return_url (선택)결제가 끝나면 돌아올 주소. 포트 번호 없는 https 주소이고, 연동 설정의 반환 허용 도메인에 등록한 도메인(하위 도메인 포함)의 주소여야 해요.

응답

HTTP/1.1 201 Created

{
  "id": "…",
  "checkout_url": "https://paydream.io/…",
  "expires_at": "2026-09-22T12:00:00.000Z"
}
  • checkout_url로 구매자를 보내요. expires_at(만든 뒤 24시간)이 지나면 그 링크의 ref와 return_url은 쓰이지 않으니, 구매자를 보낼 때마다 새로 만드세요.
  • 요청 머리글에 Idempotency-Key(공백 없는 1~128자)를 넣으면, 같은 키로 다시 보낼 때 처음 만든 주문서를 그대로 돌려줘요(200). 같은 키에 내용이 다르면 409 idempotency_conflict예요. 네트워크 오류로 다시 보낼 때 주문서가 두 개 생기지 않게 쓰세요.
  • 결제가 끝나면 구매자는 return_url?order_id=…&status=paid&ref=…로 돌아와요(ref가 없으면 생략). 결제가 실패하거나 구매자가 취소하면 return_url로 돌아오지 않아요.

임베드 — 내 사이트 안에서 결제

주문서 API로 만든 checkout_url을 내 사이트 위의 작은 창으로 열어요. 구매자는 내 페이지에 그대로 머물고, 결제가 끝나면 창이 닫히면서 order_id·ref가 내 페이지로 넘어와요. 스크립트 한 줄과 링크 속성 하나면 돼요.

<script src="https://paydream.io/embed.js"></script>

<!-- 1) 링크에 data-paydream 만 붙이기 (href = 주문서 API 응답의 checkout_url) -->
<a href="https://paydream.io/checkout?p=notion-template&cs=…" data-paydream>구매하기</a>

<!-- 2) 직접 열기 -->
<script>
  document.querySelector('#buy').addEventListener('click', async () => {
    const { checkout_url } = await fetch('/my-server/checkout', { method: 'POST' }).then(r => r.json());   // 내 서버가 주문서 API 를 부른다
    Paydream.open(checkout_url, {
      onPaid: ({ order_id, ref }) => { /* 화면 갱신 — 권한은 웹훅으로 열어요 */ },
      onClose: () => { /* 구매자가 창을 닫았어요 */ }
    });
  });
</script>
항목설명
return_url (필수)주문서 API의 return_url은 임베드를 붙인 페이지와 같은 사이트(같은 출처)의 주소여야 해요. 결제가 끝나 창이 그 주소로 돌아오는 순간을 스크립트가 읽어 창을 닫아요. 다른 도메인이면 창이 닫히지 않고 그 페이지에 머물러요.
data-paydream링크를 그대로 두고 속성만 붙여요. 끝나면 그 요소에 paydream:paid 이벤트(detail = { order_id, ref, url })가 오고, 막지 않으면(preventDefault 없음) return_url 로 이동해요. 창을 닫으면 paydream:close.
Paydream.open(url, opts)onPaid(r) · onClose() · redirect(기본 true — onPaid 가 없으면 return_url 로 이동) · veil(기본 true — 뒤 화면을 어둡게 하고 "결제 창 보기" 버튼을 보여요) · message(그 문구).
팝업이 막히면같은 창에서 주문서로 이동해요. 결제는 똑같이 되고, 끝나면 return_url 로 돌아와요. 그래서 return_url 페이지는 임베드 없이 도착해도 동작해야 해요.
열 수 있는 주소https://paydream.io/checkout?…(주문서 API)와 https://paydream.io/payment/…(결제창 링크)만. 결제창 링크는 완료 화면의 버튼을 눌러야 돌아오므로 자동으로 닫히지 않아요 — 임베드는 주문서 API와 함께 쓰세요.
창이 닫혔다고 결제가 끝난 것이 아니에요. onPaid·paydream:paid는 화면을 바꾸는 신호로만 쓰고, 상품 제공·권한은 서명을 확인한 웹훅(또는 주문 조회 API)으로만 열어요. 주소의 order_id 는 구매자가 바꿀 수 있어요.
왜 iframe 이 아니라 작은 창인가요? 주문서는 문자 인증·로그인 세션을 쓰고 결제사 창을 열어요. iframe 안에서는 사파리·크롬이 다른 사이트의 쿠키를 막아 세션이 끊겨요. 작은 창은 페이드림이 첫째 당사자라 그대로 동작해요.

구매자가 내 페이지 위의 iframe 안에서 결제하게 하려면 아래 임베드 인라인(iframe)을 보세요. 팝업은 그대로 두고 선택으로 더한 방식이에요.

임베드 인라인(iframe)

Paydream.open에 mode: 'inline'을 넘기면 팝업 대신 내 페이지 위에 iframe으로 주문서를 열어요. 구매자는 iframe 안에서 문자 인증 → 결제사 창 → 완료까지 마치고, 끝나면 iframe이 닫히면서 order_id·ref가 내 페이지로 넘어와요. 기본값은 'popup'이라 기존 연동은 바뀌지 않아요.

<script src="https://paydream.io/embed.js"></script>
<script>
  // 내 서버: POST /api/v1/checkout-sessions 에 "embed": true 를 넣어 만든다 — 구매 버튼을 그릴 때 미리
  const { checkout_url, embed } = await fetch('/my-server/checkout', { method: 'POST' }).then(r => r.json());
  Paydream.preload(checkout_url);        // 인라인으로 열 수 있는지 미리 물어 둔다(선택)
  document.querySelector('#buy').addEventListener('click', () => {
    Paydream.open(checkout_url, {
      mode: 'inline',                    // 'popup'(기본) | 'inline'
      onPaid: ({ order_id, ref }) => { /* 화면 갱신 — 권한은 웹훅으로 열어요 */ },
      onClose: () => { /* 구매자가 닫았어요 */ }
    });
  });
</script>

<!-- 링크로: data-paydream-mode="inline" 이면 마우스를 올리거나 포커스·터치할 때 알아서 미리 물어요 -->
<a href="https://paydream.io/checkout?p=…&cs=…" data-paydream data-paydream-mode="inline">구매하기</a>
항목설명
mode'popup'(기본) · 'inline'. 인라인은 뒤 화면을 어둡게 하고 가운데 iframe(src = checkout_url + '&embed=1', allow="payment")과 닫기 버튼을 보여요. 창 크기는 넓은 화면에서 최대 860×760px, 좁은 화면에서는 화면 폭 − 32px예요 — 결제사 PC 결제창이 고정 크기(토스 카드 등록 650×650 · KG이니시스 820×600)라 이보다 좁으면 잘려요.
열 수 있는 조건연동 설정의 반환 허용 도메인이 1개 이상이고, 지금 결제사가 iframe 안 결제를 지원해야 해요. 주문서 API 응답의 embed.inline이 true면 열 수 있어요. embed.js는 GET /api/checkout/embed-check?cs=…(공개, 응답 { "inline": true|false })로 한 번 더 확인해요. 미리 물어 둔 답이 불가면 클릭하는 순간 팝업으로 열고, 아직 답이 없으면 iframe을 먼저 열었다가 불가로 오면 iframe을 거두고 같은 창에서 주문서로 이동해요(클릭 뒤 비동기로 여는 팝업은 브라우저가 막기 때문이에요). 어느 쪽이든 onPaid·onClose는 같은 계약이고, 같은 창으로 갔다면 결제 뒤 return_url로 돌아와요.
Paydream.preload(url)이 주문서를 인라인으로 열 수 있는지 미리 물어 두고 Promise<boolean>을 돌려줘요. 세션마다 한 번만 묻고 답을 기억해요. checkout_url을 받자마자 불러 두면, 인라인이 안 되는 세션도 클릭 즉시 팝업으로 열려요. data-paydream-mode="inline" 링크는 마우스를 올리거나 포커스·터치할 때 알아서 불러요.
return_url주문서 API의 return_url은 임베드를 붙인 페이지와 같은 출처여야 해요. 완료 신호(postMessage)는 그 출처로만 보내고, 없으면 신호 없이 완료 화면에 링크만 보여요.
완료 신호iframe 안 완료 화면이 parent.postMessage({ type: 'paydream:paid', order_id, ref, url }, <return_url 의 출처>)를 보내요. embed.js가 event.origin === Paydream.origin을 확인한 뒤 onPaid를 부르고(없으면 url로 이동), iframe을 닫아요. 닫기는 paydream:close. data-paydream 링크에서도 팝업과 같은 paydream:paid·paydream:close 이벤트가 나요.
세션 인증iframe 안에서는 브라우저가 페이드림 쿠키를 막을 수 있어서, 주문서가 결제 세션 id(cs)를 자격 증명으로 써요(요청 머리글 X-Paydream-Checkout). 문자 인증을 마친 구매자는 그 세션에 한 번만 묶이고, 그 세션의 상품·옵션만 주문할 수 있어요. 내 서버가 신경 쓸 것은 없어요.
사파리사파리(iOS·macOS)는 iframe 안에서 페이드림 쿠키를 저장·전송하지 않아요(파이어폭스·크롬도 다른 사이트 안의 로그인 쿠키는 보내지 않아요). 그래서 인라인 주문서는 쿠키 없이 결제 세션으로 문자 인증 → 주문 → 결제창까지 이어져요 — 아래 표의 WebKit 결과가 그 흐름이에요. 구매자가 페이드림 회원이어도 iframe 안에서는 비회원 휴대폰 인증으로 결제해요.
표시 규칙checkout_url&embed=1 응답의 Content-Security-Policy: frame-ancestors가 내 반환 허용 도메인(https://내도메인 https://*.내도메인)으로 열려요. 등록하지 않은 사이트에서 iframe을 열면 브라우저가 막아요.

결제사별 지원 — iframe 안에서 결제창이 그려지는지 실제 브라우저로 확인한 결과예요(2026-09-26, 테스트 결제창, 카드 정보는 넣지 않음). 지금 결제사가 '지원'이 아니면 embed.inline이 false(provider_not_supported)이고 팝업으로 열려요.

결제사인라인결과
토스페이먼츠지원단건(결제위젯 → 카드사 창)·구독(카드 등록 창) 모두 iframe 안에 그려져요. 크롬·사파리(WebKit)·파이어폭스 엔진의 PC 화면과 모바일 화면(아이폰·안드로이드 크기)에서 확인했어요.
페이플지원결제창이 iframe 자체를 결제 화면으로 바꿔 그려요(단건·구독). 같은 브라우저·기기에서 확인했어요.
포트원(KG이니시스)지원PC는 이니시스 결제창이 iframe 안에, 모바일은 iframe 자체가 이니시스 모바일 결제 화면으로 바뀌어 그려져요(단건·구독). 같은 브라우저·기기에서 확인했어요.

주문서 API 응답 embed

{ "id": "…", "checkout_url": "…", "expires_at": "…", "embed": { "inline": false, "reason": "provider_not_supported" } }
reason뜻
provider_not_supported지금 결제사는 iframe 안 결제를 지원하지 않아요 (팝업으로 열려요)
no_return_domains연동 설정에 반환 허용 도메인이 없어요
not_ready인라인 결제가 아직 열리지 않았어요
인라인도 완료 신호는 화면용이에요. paydream:paid는 postMessage라 내 페이지의 다른 스크립트가 흉내 낼 수 있어요. 상품 제공·권한은 서명을 확인한 웹훅으로만 열어요.

주문·상품 조회

내 상품의 주문만 조회돼요. 웹훅을 놓쳤을 때 확인용으로 써요.

GET https://paydream.io/api/v1/orders/{order_id}

{
  "id": "PD2609211403AB12",
  "status": "paid",
  "amount": 27000,
  "refund_amount": 0,
  "kind": "once",
  "ref": "ord_9f1c2e7a4b6d",
  "product_id": "…",
  "option_id": "…",
  "buyer_email": "buyer@example.com",
  "paid_at": "2026-09-21T05:03:10.000Z",
  "refunded_at": null,
  "created_at": "2026-09-21T05:02:40.000Z"
}
필드설명
id주문번호. 웹훅의 merchantUid와 같아요.
statuspending(결제 전) · processing(승인 중) · paid(결제 완료) · refunding(취소 중) · refunded(전액 환불) · failed(실패). 일부만 환불되면 paid로 남고 refund_amount가 늘어요.
amount · refund_amount결제 금액과 지금까지 환불된 금액 합계(원)
kindonce 일반결제 · sub 정기결제 회차
시각 필드paid_at · refunded_at · created_at, UTC ISO-8601. 없으면 null
metadata주문서 API에 넘긴 metadata 객체. 없으면 null
customer_id구매자 id. 고객 조회에 넣어요
license상품이 라이선스 키를 발급하면 { "id", "key_last4", "status" }, 아니면 null. 키 원문은 조회 API에 실리지 않아요
GET https://paydream.io/api/v1/products

{
  "data": [
    {
      "id": "…", "slug": "notion-template", "title": "노션 업무 자동화 템플릿",
      "type": "DOCUMENT", "pay_type": "once", "mode": "window", "status": "live", "price": 27000,
      "options": [ { "id": "opt_2Kx9", "name": "-", "price": 30000, "cycle_months": null } ]
    }
  ]
}

상품 목록은 페이지를 나누지 않고 한 번에 와요. type은 DOCUMENT(자료) · SERVICE(서비스) · EVENT(이벤트) · GOODS(제품), mode는 page(판매 페이지) · window(결제창)예요.

주문 목록

내 주문을 기간·상태·상품으로 걸러 가져와요. 항목 모양은 주문 조회와 같고 metadata·license가 함께 실려요.

GET https://paydream.io/api/v1/orders?since=2026-09-01T00:00:00Z&status=paid&limit=50

{
  "data": [ { "id": "PD2609211403AB12", "status": "paid", "amount": 27000, …, "metadata": { "user_id": "u_1" }, "license": null } ],
  "has_more": true,
  "next_cursor": "MjAyNi0wOS0yMVQwNTowMjo0MC4wMDBafFBEMjYwOTIxMTQwM0FCMTI"
}
쿼리설명
since · until만든 시각(created_at) 범위, RFC3339(예 2026-09-01T00:00:00Z). since는 포함, until은 미포함
statuspaid · refunding · refunded · failed · pending 중 하나
product내 상품의 id 또는 상품 주소(slug)
limit한 번에 받을 개수. 기본 20, 최대 100
cursor이전 응답의 next_cursor를 그대로 넘겨요

목록 넘기기

주문·구독·라이선스 목록은 모두 같은 방식이에요. 응답의 has_more가 true면 next_cursor를 다음 요청의 cursor로 넘겨요. 커서는 뜻을 해석하지 말고 그대로 쓰는 불투명 문자열이고, 최신 주문부터 내려가요. has_more가 false면 next_cursor는 null이에요.

코드 (HTTP 상태)뜻
invalid_since · invalid_until · invalid_status · invalid_limit (400)since·until이 RFC3339가 아니거나, status가 목록에 없거나, limit이 1~100의 정수가 아니에요
invalid_cursor (400)cursor가 이 API가 준 값이 아니에요

구독

정기결제를 조회하고 판매자 쪽에서 해지해요. 해지한 구독은 구매자가 다시 켤 수 없어요.

GET  https://paydream.io/api/v1/subscriptions/{subscription_id}
GET  https://paydream.io/api/v1/subscriptions?status=active&limit=20&cursor=…
POST https://paydream.io/api/v1/subscriptions/{subscription_id}/cancel
{ "immediate": false, "reason": "서비스 종료로 정기결제를 해지해요. 남은 기간은 그대로 이용할 수 있어요." }

{
  "id": "sub_…", "status": "canceled",
  "product": "…", "option": "…", "amount": 9900, "cycle_months": 1,
  "period_end": "2026-10-21T05:03:10.000Z", "anchor_day": 21,
  "ref": "ord_9f1c2e7a4b6d", "metadata": { "user_id": "u_1" },
  "created_at": "2026-09-21T05:03:10.000Z", "canceled_at": "2026-09-25T02:00:00.000Z", "ended_reason": null
}
필드설명
statusactive · past_due(갱신 실패, 재시도 중) · renewing(이번 회차 결제 중) · canceled(해지 예약 — 기간 끝까지 이용) · ended(끝남). 이용 권한은 웹훅 subscription.terminated로 막아요
amount · cycle_months회차 금액(원)과 주기(개월, 1~12). 시작한 정기결제의 금액·주기는 바뀌지 않아요
period_end · anchor_day이용 가능한 마지막 시각과 매달 결제일
ref · metadata첫 결제의 참조값과 metadata(첫 주문의 것)
canceled_at · ended_reason해지 시각, 끝난 사유(CANCELED · SELLER_CANCELLED · PAYMENT_FAILED · PRODUCT_UNAVAILABLE). 없으면 null
해지 본문 immediate기본 false = 낸 기간 끝에 끝나요(subscription.cancel_requested → 기간 끝에 subscription.terminated). true = 바로 끝나요(두 이벤트가 곧이어 와요)
해지 본문 reason10~500자. 구매자에게 그대로 보이고 웹훅 cancelDetailReason에 실려요. 목록의 status 쿼리는 위 상태 값 중 하나
코드 (HTTP 상태)뜻
subscription_not_found (404)내 상품의 정기결제 중에 그 id가 없어요
invalid_reason (400)reason이 10~500자가 아니에요
subscription_not_cancelable (409)이미 해지·종료됐거나 이번 회차 결제를 처리하는 중이에요. 잠시 뒤 다시 보내세요
invalid_limit · invalid_status · invalid_cursor (400)주문 목록과 같아요

환불

내 상품의 결제를 판매자가 직접 환불해요. 규칙은 판매 관리 화면의 환불과 같아요 — 정산금이 지급되기 전 주문만, 부분 환불도 돼요. 지급 뒤 환불은 help@paydream.io로 알려 주세요.

POST https://paydream.io/api/v1/refunds
Idempotency-Key: rf_PD2609211403AB12
Content-Type: application/json

{ "order": "PD2609211403AB12", "amount": 10000, "reason": "change_mind", "detail": "구매자 요청" }

HTTP/1.1 200 OK   (취소가 아직 진행 중이면 202 Accepted + status "refunding")
{ "id": "rf_…", "order": "PD2609211403AB12", "amount": 10000, "status": "refunded" }
필드설명
order (필수)주문번호(merchantUid)
amount (선택)환불 금액(원). 생략하면 남은 전액. 남은 금액(amount − refund_amount)을 넘을 수 없어요
reason (필수)change_mind 단순 변심 · not_as_described 설명과 다름 · not_delivered 미제공 · other 기타
detail (선택)상세 사유. 구매자 메일에 실려요
응답 statusrefunded 결제사 취소까지 끝남 · refunding 취소 처리 중(끝나면 웹훅 payment.refunded). 전액 환불이면 그 주문의 라이선스 키는 회수돼요
Idempotency-Key주문서 API와 같은 규칙. 같은 키로 다시 보내면 처음 결과(200)를 돌려주고 24시간 기억해요 — 네트워크 오류로 다시 보낼 때 두 번 환불되지 않게 꼭 쓰세요
코드 (HTTP 상태)뜻
order_not_found (404)내 상품의 주문 중에 그 주문번호가 없어요
invalid_reason (400)reason이 네 값 중 하나가 아니에요
invalid_amount (400)amount가 1원 이상의 정수가 아니거나 남은 금액을 넘어요
order_not_refundable (409)결제 완료 상태가 아니거나 이미 전액 환불됐어요
already_paid_out (409)이미 정산금이 지급된 주문이라 페이드림이 처리해요
refund_failed (502)결제사 취소가 실패했어요. 원장은 바뀌지 않았으니 잠시 뒤 같은 Idempotency-Key로 다시 보내세요
invalid_idempotency_key (400) · idempotency_conflict (409)오류 표와 같아요

고객

내 상품을 산 구매자 한 명을 조회해요. customer_id는 구매자 사용자 id예요. 내 상품을 산 적이 없는 사용자는 404로 보여요 — 존재 여부도 알려 주지 않아요.

GET https://paydream.io/api/v1/customers/{customer_id}

{
  "id": "u_…", "kind": "guest", "name": "홍길동",
  "email": null, "phone": "010-****-1234",
  "orders_count": 3, "first_paid_at": "2026-08-01T02:10:00.000Z", "last_paid_at": "2026-09-21T05:03:10.000Z"
}
필드설명
kindmember 회원 · guest 문자 인증 비회원
email · phone비회원은 email이 null. 전화번호는 마지막 4자만 보여요(010-****-1234). 판매 관리의 주문 상세가 보여 주는 범위를 넘지 않아요
orders_count · first_paid_at · last_paid_at내 상품 결제 완료 건수와 첫·마지막 결제 시각
코드 (HTTP 상태)뜻
customer_not_found (404)내 상품을 산 구매자 중에 그 id가 없어요

고객 포털

내 사이트에 "내 구매 보기" 버튼을 달 수 있어요. 버튼을 누르면 내 서버가 이 API로 그 구매자의 포털 링크를 받아 그 주소로 보내요. 구매자는 페이드림 로그인 없이 내 상점에서 산 주문과 구독만 봐요 — 파일 받기·링크 열기, 라이선스 키, 영수증, 셀프 환불·환불 요청, 구독 해지·해지 취소. 다른 판매자에게 산 것은 보이지 않아요.

본인 확인: 목록·상세·상태·영수증과 라이선스 키 마지막 4자는 링크만으로 보여요. 하지만 파일 받기·링크 열기·이용 안내 보기(제공 시작), 라이선스 키 전체 보기, 환불 요청·셀프 환불, 구독 해지·해지 취소를 하려면 구매자가 휴대폰 문자 인증번호를 한 번 넣어야 해요(구매 때 문자로 확인된 번호로 보내요, 010-****-1234처럼 가려서 보여 줘요. 확인된 번호가 없는 회원은 페이드림에 로그인해 구매 내역에서 해요). 링크는 판매자 서버가 만들기 때문에 판매자도 열 수 있어요 — 판매자가 대신 받기를 눌러 청약철회(7일 환불)를 막거나 키 원문을 보거나 구매자 대신 환불·해지를 할 수 없게 하려는 확인이에요. 인증은 그 포털 화면(1시간)에서만 유효하고 페이드림 로그인이 되지는 않아요. 휴대폰 번호가 없는 옛 계정은 포털에서 이런 일을 할 수 없고 페이드림 구매 내역으로 안내돼요. 구매자에게 "받기 전에 문자 인증번호를 한 번 넣게 돼요"라고 알려 두면 좋아요.

POST https://paydream.io/api/v1/customers/{customer_id}/portal
Authorization: Bearer pd_live_…
Content-Type: application/json

{ "return_url": "https://my.site/account" }

HTTP/1.1 201 Created
{ "url": "https://paydream.io/portal/enter?t=…", "expires_at": "2026-09-26T05:15:00.000Z" }
필드설명
customer_id주문 조회·주문 목록의 customer_id. 웹훅을 받으면 그 주문을 조회해 내 회원과 묶어 두세요
return_url (선택)포털 머리글과 만료 화면의 "돌아가기" 주소. 주문서 API처럼 연동 설정에 등록한 내 도메인의 https 주소만 받아요. 넣을 값이 없으면 본문에 {}를 보내요
url구매자를 보낼 주소. 15분 안에 한 번만 열려요. 판매 관리의 연동 설정에서 내 결제 도메인(pay.my-shop.com)을 연결해 두었으면 그 도메인 주소로 와요
expires_at링크가 만료되는 시각(UTC). 연 뒤의 포털 화면은 1시간 동안 열려 있어요

링크는 저장해 두지 말고 버튼을 누를 때마다 새로 만드세요.

// Node — 내 사이트에 로그인한 그 구매자 본인일 때만 링크를 만든다
app.get('/my/purchases', requireLogin, async (req, res) => {
  const r = await fetch(`https://paydream.io/api/v1/customers/${req.user.paydreamCustomerId}/portal`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.PAYDREAM_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ return_url: 'https://my.site/account' })
  });
  if (!r.ok) return res.status(502).send('구매 내역을 열지 못했어요.');
  res.redirect(303, (await r.json()).url);
});

// SDK: const { url } = await pd.customers.portal(customerId, { return_url: 'https://my.site/account' });

<!-- 내 사이트의 버튼 -->
<a href="/my/purchases">내 구매 보기</a>
보안지킬 것
서버에서만API 키가 필요한 호출이라 반드시 내 서버에서 만드세요. 브라우저·앱 코드에 API 키를 넣지 마세요
본인에게만링크를 가진 사람은 누구든 그 구매자의 포털을 열어요. 내 사이트에 로그인한 그 구매자에게만 만들어 주세요
보내지 않기링크를 메일·문자·메신저로 보내거나 로그에 남기지 마세요. 전달되면 받은 사람이 열 수 있어요. 메일로 안내하려면 "내 구매 보기" 버튼이 있는 내 사이트 주소를 보내세요
로그인과 별개포털 화면은 페이드림 계정 로그인이 아니에요. 포털로는 페이드림 계정·판매 관리·다른 판매자의 구매 내역에 들어갈 수 없어요
연 도메인에서만링크는 url의 호스트에서만 열리고, 열린 포털 화면도 그 호스트에서만 이어져요(내 결제 도메인에서 연 화면을 paydream.io에서 쓸 수 없고 그 반대도 마찬가지예요)
코드 (HTTP 상태)뜻
customer_not_found (404)내 상품을 산 구매자 중에 그 id가 없어요
invalid_return_url (400)return_url이 https 주소가 아니거나, 연동 설정에 등록한 내 도메인이 아니에요
invalid_json (400 · 413 · 415)본문이 JSON 객체가 아니에요 — 넣을 값이 없으면 {}

metadata

주문서를 만들 때 metadata에 내 쪽 값을 넣으면, 그 주문서로 결제된 주문에 그대로 붙어 조회 API와 웹훅에 실려요. ref가 값 하나라면 metadata는 여러 키를 담는 자리예요.

POST https://paydream.io/api/v1/checkout-sessions
{ "product": "notion-template", "return_url": "https://my.site/thanks",
  "metadata": { "user_id": "u_1", "plan": "pro", "seats": 3, "trial": false } }

HTTP/1.1 201 Created
{ "id": "…", "checkout_url": "…", "expires_at": "…", "metadata": { "user_id": "u_1", "plan": "pro", "seats": 3, "trial": false }, "embed": { … } }
규칙설명
모양JSON 객체. 키 20개 이하, 키 40자 이하, 값은 500자 이하의 문자열·숫자·불리언만(객체·배열·null은 안 돼요). 직렬화해서 2KB 이하
어디에 실리나주문서 API 응답(에코), 주문 조회·주문 목록·구독(첫 주문의 것)의 metadata, 그리고 모든 결제·구독 웹훅 data.object.metadata. 없으면 null
보이는 범위구매자에게는 보이지 않지만 비밀값은 넣지 마세요. 개인정보 대신 내 쪽 id를 넣으세요
코드 (HTTP 상태)뜻
invalid_metadata (400)위 규칙에 맞지 않아요 (객체가 아님, 키·값 개수나 길이 초과, 허용하지 않는 값 유형, 2KB 초과)

라이선스 키

자료·서비스 상품(일반결제)에 라이선스 키 발급을 켜면 결제가 확정될 때 키 PD-XXXX-XXXX-XXXX-XXXX(대문자·숫자, 혼동되는 0 O 1 I는 빼요)가 만들어져요. 키 원문은 구매자의 결제 완료 메일과 주문 화면에만 보이고, 웹훅·조회 API에는 실리지 않아요(key_last4만). 활성 기기 수(1~10, 기본 3)는 상품 관리에서 정해요.

라이선스 확인은 내 서버가 API 키로 불러요. 최종 사용자 앱이 직접 부르는 공개 주소는 없어요 — 앱 → 내 서버 → 페이드림 순서로 이어 주세요.

POST https://paydream.io/api/v1/licenses/activate
{ "key": "PD-7K2M-9XQ4-H3PB-VW8N", "label": "MacBook Pro" }

{
  "activated": true, "instance_id": "act_…",
  "license": { "id": "lic_…", "key_last4": "VW8N", "status": "active", "activation_limit": 3, "activations": 1 }
}

POST https://paydream.io/api/v1/licenses/validate
{ "key": "PD-7K2M-9XQ4-H3PB-VW8N", "instance_id": "act_…" }
→ { "valid": true, "license": { … } }            또는 { "valid": false, "license": { … }, "reason": "license_revoked" }

POST https://paydream.io/api/v1/licenses/deactivate
{ "key": "PD-7K2M-9XQ4-H3PB-VW8N", "instance_id": "act_…" }
→ { "deactivated": true }

GET  https://paydream.io/api/v1/licenses?order=&product=&status=&limit=&cursor=
GET  https://paydream.io/api/v1/licenses/{license_id}
POST https://paydream.io/api/v1/licenses/{license_id}/revoke   { "reason": "환불 외 사유" }
항목설명
activate키에 기기(인스턴스)를 하나 등록해요. label(선택, 60자)은 기기 이름. 응답의 instance_id를 그 기기에 저장해 두고 validate·deactivate에 써요. 키 비교는 해시로 하니 대소문자·하이픈을 그대로 보내세요
validate그 기기의 등록이 살아 있는지 확인해요. valid: false일 때 reason: license_not_found(내 키가 아님, license는 null) · license_revoked(회수됨) · instance_not_found(그 instance_id가 등록되지 않았거나 해제됨). 이 세 경우도 HTTP 200이에요. 앱 실행마다 부르지 말고 하루 한 번 정도 캐시하세요
deactivate기기 등록을 풀어요 → { "deactivated": true }. 이미 풀린 기기를 다시 풀면 404 instance_not_found
license 객체id · key_last4 · status(active · revoked) · activation_limit · activations(지금 등록된 기기 수). 목록·단건 조회에는 order_id · product_id · created_at · revoked_at · revoked_reason(refund · seller · admin) · revoked_detail(회수할 때 적은 사유, 없으면 null)도 실려요
회수전액 환불되면 자동으로 revoked(refund). revoke로 직접 회수할 수도 있어요(reason 선택, 200자 — revoked_detail로 저장돼요. 이미 회수된 키는 처음 사유가 그대로예요). 판매 관리의 주문 상세 화면에서 회수해도 seller, 페이드림이 회수하면 admin이에요. 회수된 키는 activate가 403, validate가 valid: false예요
요청 제한API 공통 분당 120번 안에서, activate·validate 실패는 판매자별 분당 30번을 따로 세요(키 무차별 대입 방지). 넘으면 429 rate_limited
웹훅 license.created키가 만들어지면 보내요. data.object = id · key_last4 · order_id · product · activation_limit · metadata. 키 원문은 없어요 — 판매자는 activate·validate로 확인해요. 연동 설정에서 이 이벤트를 골라야 와요
코드 (HTTP 상태)뜻
license_not_found (404)내 상품의 키 중에 그 키(또는 license_id)가 없어요. 다른 판매자의 키도 404예요
license_revoked (403)회수된 키로 activate했어요
activation_limit (409)활성 기기 수를 넘었어요. 다른 기기를 deactivate한 뒤 다시 등록하세요
invalid_request (400)key가 없거나 instance_id가 없어요. 없는 키는 activate·deactivate에서 404 license_not_found, 해제할 기기가 없으면 404 instance_not_found
invalid_limit · invalid_status · invalid_cursor (400)주문 목록과 같아요

웹훅

판매 관리 > 연동에서 웹훅 주소(https) 하나와 받을 이벤트를 골라 저장해요. 고른 이벤트만 보내요. 처음 저장할 때 서명 비밀값을 한 번만 보여 줘요. 저장한 뒤 테스트 발송(예시 값, 다시 보내지 않음)으로 연결을 확인할 수 있고, 연동 화면에서 최근 전송 기록(응답 코드·시도 횟수)을 볼 수 있어요.

  • 주소는 포트 번호·아이디/비밀번호 없는 https 도메인 주소여야 해요. IP 주소나, 사설·내부망 IP로 연결되는 도메인은 저장되지 않아요.
  • 비밀값은 24시간에 한 번 새로 만들 수 있어요. 새로 만든 뒤 24시간 동안은 이전 비밀값 서명을 X-Paydream-Signature-Previous로 함께 보내니, 그 사이에 받는 서버의 값을 바꾸면 돼요.
이벤트보내는 때
payment.completed일반결제가 완료됨 (무료 상품 포함)
payment.cancel_requested구매자가 환불(결제 취소)을 요청함. 요청일 뿐이니 최종 처리 신호로 쓰지 마세요
payment.refunded일반결제 환불이 실제로 처리됨. 부분 환불이면 환불할 때마다 1건
subscription_payment.completed정기결제 첫 결제와 이후 매 회차 결제가 완료됨
subscription_payment.refunded정기결제 회차 결제가 환불됨
subscription_payment.failed정기결제 갱신 결제가 실패함 (시도마다 1건)
subscription.cancel_requested구매자나 판매자가 정기결제를 해지함 (남은 기간은 이용 가능)
subscription.terminated정기결제가 실제로 끝남. 이용 권한을 막는 기준이에요
license.created라이선스 키가 발급됨. 키 원문은 싣지 않아요

요청 모양

POST {내 웹훅 주소}
Content-Type: application/json
X-Paydream-Timestamp: 1790000000
X-Paydream-Signature: 9f86d081884c7d65…
X-Paydream-Idempotency-Key: evt_0123456789abcdefghijklmn

{
  "id": "evt_0123456789abcdefghijklmn",
  "type": "payment.completed",
  "version": "2026-09-21",
  "occurredAt": "2026-09-21T14:03:12+09:00",
  "data": { "object": { … } }
}
머리글값
X-Paydream-Signature지금 비밀값으로 만든 서명 (HMAC-SHA256, 16진수)
X-Paydream-Timestamp서명에 쓴 시각 (Unix 초). 다시 보낼 때마다 그 시각으로 새로 서명해요
X-Paydream-Idempotency-Key이벤트 id와 같아요. 다시 보낼 때도 같은 값이니 이 값으로 중복을 거르세요
X-Paydream-Signature-Previous비밀값을 교체한 뒤 24시간 동안만, 이전 비밀값으로 만든 서명
  • 봉투: id(evt_ + 24자) · type · version(지금 2026-09-21) · occurredAt(이벤트가 실제로 일어난 시각) · data.object(이벤트 내용)
  • 값이 없는 필드는 키를 빼고 보내요(null을 넣지 않아요). 금액은 모두 원 단위 정수, 시각은 한국 시각 ISO-8601(+09:00), 코드 값은 대문자예요.
  • 새 필드와 값은 예고 없이 추가될 수 있어요. 모르는 키와 값은 무시하세요.
  • 도착 순서는 보장하지 않아요. 순서가 필요하면 occurredAt으로 판단하세요.
  • 결제·구독 이벤트의 data.object.metadata에 주문서 API의 metadata가 실려요(없으면 키 자체가 생략돼요 — 웹훅은 값 없는 키를 싣지 않아요). 기존 필드의 이름·순서는 그대로예요.

결제 완료 내용 (payment.completed)

{
  "merchantUid": "PD2609211403AB12",
  "sellerReference": "ord_9f1c2e7a4b6d",
  "buyer": { "type": "GUEST", "displayName": "홍길동", "email": "buyer@example.com", "phoneNumber": "010-0000-0000" },
  "content": { "id": "…", "title": "노션 업무 자동화 템플릿", "type": "DOCUMENT", "paymentType": "ONE_TIME", "inputMode": "PAYMENT_WINDOW" },
  "options": [{
    "optionId": "opt_2Kx9", "name": "-", "quantity": 1,
    "unitOriginalPrice": 30000, "unitDiscountedPrice": 27000,
    "discount": { "type": "PERCENT", "value": 10, "timeDeal": { "label": "오픈 할인" } },
    "additionalOptions": [], "subtotal": 27000
  }],
  "pricing": { "currency": "KRW", "originalAmount": 30000, "optionDiscountAmount": 3000, "finalAmount": 27000 },
  "paymentMethod": { "type": "CARD", "cardName": "…", "maskedCardNumber": "…" },
  "questionAnswers": [{ "question": "어떤 경로로 알게 되셨나요?", "questionType": "TEXT", "required": false, "answer": "인스타그램", "displayOrder": 1 }],
  "payment": { "purchasedAt": "2026-09-21T14:03:10+09:00" }
}
필드설명
merchantUid페이드림 주문번호. 결제 건을 맞추는 키예요
sellerReferenceref로 넘긴 내 참조값 (넘기지 않았거나 버려졌으면 없음)
buyertype(MEMBER 회원 · GUEST 비회원), displayName, email, phoneNumber
content상품 id, 결제 당시 title, type(DOCUMENT · SERVICE · EVENT · GOODS), paymentType(ONE_TIME · SUBSCRIPTION), inputMode(NORMAL 판매 페이지 · PAYMENT_WINDOW 결제창)
options[]항상 배열이고 지금은 1개예요. 옵션 id·이름, 수량(1), 할인 전후 단가, 할인(AMOUNT 원 · PERCENT %, 기간 할인이면 timeDeal.label), additionalOptions(항상 []), 소계
pricingcurrency(KRW), 할인 전 총액, 할인 합계, shippingFee(제품만, 최종 금액에 포함), finalAmount(실제 결제 금액)
paymentMethodtype(CARD · 무료면 FREE), 카드사명, 일부를 가린 카드번호
shipping제품만. address · streetAddress · detailAddress · deliveryRequest
questionAnswers[]상품에 설문을 넣었으면 질문·유형·필수 여부·답·순서
payment.purchasedAt결제 시각

이벤트별로 더해지는 내용

이벤트결제 완료와 다른 점
payment.cancel_requestedcancelRequest: reason(code: 단순 변심이면 CHANGED_MIND, 그 밖은 ETC — 사유는 label에), 구매자가 쓴 detailReason(10~500자), requestedBy(BUYER), requestedAt. sellerReference는 없어요
payment.refundedrefund: 이번 환불 amount, currency, partialRefund, cancelledBy(BUYER 구매자가 바로 취소했거나 구매자의 환불 요청을 판매자·페이드림이 승인 · SELLER 판매자가 직접 환불 · ADMIN 페이드림이 직접 환불), reason, refundedAt. pricing은 원래 결제 금액 그대로이고, 누적 환불액은 받는 쪽에서 더하세요. sellerReference·paymentMethod·questionAnswers는 없어요
subscription_payment.completedsubscription: billingReason(INITIAL 첫 결제 · RENEWAL 갱신), currentRound, nextBillingDate, status, activatedAt, lastBillingSucceededAt, billingCycleMonths(1~12). paymentMethod·questionAnswers는 첫 결제에만
subscription_payment.refundedrefund(위와 같음)와 subscription(refundedRound 포함). 이용 권한은 subscription.terminated로 판단하세요
subscription_payment.failedfailure: attemptNumber, isFinal, reason, failedAt, 다시 시도할 때만 nextRetryAt(하루 뒤). 3번째 실패가 마지막(isFinal: true)이고 유예 기간 없이 바로 끝나요(gracePeriodEndsAt = 실패 시각, 곧이어 subscription.terminated). merchantUid는 시도마다 새 값이니 정기결제는 sellerReference로 이으세요
subscription.cancel_requestedsubscription(cancelledAt·price 포함)과 cancelRequest(reason.code: NOT_USED · MISSING_FEATURES · TOO_EXPENSIVE · OTHER, 판매자가 해지하면 reason은 빈 객체, requestedBy: BUYER · SELLER). 판매자 해지면 cancellationSource와 이용 가능한 마지막 시각 serviceEndsAt
subscription.terminatedtermination: 종료 사유 reason(PERIOD_END 해지 뒤 기간 끝 · PAYMENT_FAILURE_GRACE_EXPIRED 결제 실패 · SELLER_CANCELLED 판매자 해지로 즉시 · CONTENT_DELETED 판매 중단·종료 · REFUNDED 마지막 회차 환불. 값이 늘어날 수 있으니 모르는 값이면 terminatedAt만 보고 처리), terminatedAt. 누가 끝냈는지 cancelledBy(BUYER · SELLER · ADMIN · SYSTEM), 판매자 해지면 cancellationSource와 판매자가 쓴 cancelDetailReason

sellerReference는 payment.completed, subscription_payment.completed(모든 회차), subscription_payment.failed, subscription.cancel_requested, subscription.terminated에 실려요. 나머지 이벤트는 merchantUid로 원래 결제와 이으세요. 결제 완료를 받을 때 merchantUid와 sellerReference를 함께 저장해 두면 편해요.

서명 확인

  1. 본문을 받은 바이트 그대로 읽어요. 파싱한 뒤 다시 문자열로 만들면 서명이 맞지 않아요.
  2. X-Paydream-Timestamp 값과 본문을 점으로 이어 {timestamp}.{본문}을 만들고, 서명 비밀값으로 HMAC-SHA256을 계산해 16진수로 바꿔요.
  3. 그 값이 X-Paydream-Signature 또는 X-Paydream-Signature-Previous와 같은지 비교해요. 길이를 먼저 확인하고, 같은 길이면 상수 시간 비교 함수로 비교하세요.
  4. timestamp가 지금 시각과 5분 넘게 차이 나면 버려요.
  5. 모두 통과했을 때만 JSON으로 읽어요.

Node.js

import crypto from 'node:crypto';

// rawBody = 받은 본문 바이트 그대로(Buffer). JSON.parse 한 뒤 다시 문자열로 만들면 서명이 맞지 않아요.
export function verifyPaydream(rawBody, headers, secret) {
  const ts = headers['x-paydream-timestamp'];
  if (!/^\d+$/.test(ts || '') || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
  const expected = Buffer.from(
    crypto.createHmac('sha256', secret).update(`${ts}.`).update(rawBody).digest('hex'));
  return [headers['x-paydream-signature'], headers['x-paydream-signature-previous']].some(sig => {
    if (typeof sig !== 'string') return false;
    const got = Buffer.from(sig.toLowerCase());
    // 길이부터 확인한다. 길이가 다르면 timingSafeEqual 이 예외를 던진다
    return got.length === expected.length && crypto.timingSafeEqual(got, expected);
  });
}
// Express 예시: 본문을 파싱하지 않은 바이트로 받는다
app.post('/webhooks/paydream', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyPaydream(req.body, req.headers, process.env.PAYDREAM_WEBHOOK_SECRET)) return res.sendStatus(401);
  const event = JSON.parse(req.body);
  // event.id(= X-Paydream-Idempotency-Key)로 이미 처리한 이벤트인지 먼저 확인한다
  res.sendStatus(200);   // 10초 안에 2xx. 오래 걸리는 일은 응답한 뒤에 한다
});

Python

import hashlib, hmac, time

def verify_paydream(raw_body: bytes, headers, secret: str) -> bool:
    """raw_body = 받은 본문 바이트 그대로. headers 는 대소문자를 가리지 않는 객체(Flask·Django 등)."""
    ts = headers.get("X-Paydream-Timestamp") or ""
    if not (ts.isascii() and ts.isdigit()) or abs(time.time() - int(ts)) > 300:
        return False
    expected = hmac.new(secret.encode(), ts.encode() + b"." + raw_body, hashlib.sha256).hexdigest().encode()
    for name in ("X-Paydream-Signature", "X-Paydream-Signature-Previous"):
        sig = (headers.get(name) or "").lower().encode()
        # 길이부터 확인한 뒤 상수 시간으로 비교
        if len(sig) == len(expected) and hmac.compare_digest(sig, expected):
            return True
    return False
# Flask 예시
@app.post("/webhooks/paydream")
def paydream_webhook():
    if not verify_paydream(request.get_data(), request.headers, os.environ["PAYDREAM_WEBHOOK_SECRET"]):
        return "", 401
    event = request.get_json()
    # event["id"](= X-Paydream-Idempotency-Key)로 이미 처리한 이벤트인지 먼저 확인한다
    return "", 200

응답과 다시 보내기

웹훅을 받으면 10초 안에 2xx로 응답해 주세요. 오래 걸리는 처리는 응답한 뒤에 하세요. 리다이렉트는 따라가지 않으니 최종 주소를 등록하세요.

내 서버의 응답페이드림의 처리
2xx성공
408 · 429 · 5xx, 네트워크 오류, 10초 초과아래 간격으로 다시 보내요
410이 주소로 보내기를 바로 멈춰요 (웹훅 비활성화)
그 밖의 4xx다시 보내지 않아요 (최종 실패)

다시 보내는 간격은 1분 → 5분 → 30분 → 2시간 → 6시간 → 12시간 → 24시간으로, 최대 7번이에요. 연속으로 20건 이상 실패하고 마지막 성공 뒤 3일이 지나면 웹훅이 비활성화돼요.

하고 싶은 일이 값으로
같은 이벤트를 두 번 처리하지 않기X-Paydream-Idempotency-Key (= 이벤트 id)
결제 건 찾기merchantUid
정기결제 전체를 내 회원과 잇기sellerReference

SDK(Node)

@paydream/node는 위 API를 감싼 작은 클라이언트와 웹훅 서명 검증이에요. 의존성 없이 Node 20.3 이상의 fetch·crypto만 써요. 타입 정의(index.d.ts)가 들어 있어요. npm 레지스트리 대신 페이드림 주소에서 바로 설치해요.

npm install --allow-remote=root https://paydream.io/sdk/paydream-node-0.1.0.tgz     # 버전 고정 (권장 — package-lock 에 그대로 남아요)
npm install --allow-remote=root https://paydream.io/sdk/paydream-node-latest.tgz    # 가장 새 것
import { Paydream, PaydreamError, verifyWebhook } from '@paydream/node';

const pd = new Paydream(process.env.PAYDREAM_API_KEY);        // pd_live_… | pd_test_…  ({ baseUrl, timeout, maxRetries } 로 바꿀 수 있어요)

// 주문서 만들기 — 두 번째 인자에 멱등 키
const session = await pd.checkoutSessions.create(
  { product: 'notion-template', ref: 'ord_9f1c', return_url: 'https://my.site/thanks', metadata: { user_id: 'u_1' } },
  { idempotencyKey: 'ord_9f1c' });

// 조회·목록 (목록은 for await 로 끝까지)
const order = await pd.orders.get('PD2609211403AB12');
for await (const o of pd.orders.iterate({ since: '2026-09-01T00:00:00Z', status: 'paid' })) { /* … */ }
const page = await pd.orders.list({ limit: 100 });             // { data, has_more, next_cursor } 그대로

// 구독·환불·고객·라이선스
await pd.subscriptions.cancel('sub_…', { reason: '서비스 종료로 정기결제를 해지해요. 남은 기간은 이용할 수 있어요.' });
await pd.refunds.create({ order: order.id, reason: 'change_mind' }, { idempotencyKey: 'rf_' + order.id });
const customer = await pd.customers.get('u_…');
const { instance_id } = await pd.licenses.activate({ key: 'PD-7K2M-9XQ4-H3PB-VW8N', label: 'MacBook' });
const { valid } = await pd.licenses.validate({ key: 'PD-7K2M-9XQ4-H3PB-VW8N', instance_id });

// 오류 = PaydreamError { status, code, message, body }
try { await pd.refunds.create({ order: 'PD…', reason: 'other' }); }
catch (e) {
  if (e instanceof PaydreamError && e.code === 'already_paid_out') { /* 지급 뒤 — 페이드림에 요청 */ }
  else throw e;
}
메서드부르는 API
checkoutSessions.create(body, { idempotencyKey })POST /api/v1/checkout-sessions
products.list()GET /api/v1/products
orders.get(id) · orders.list(params) · orders.iterate(params)GET /api/v1/orders/:id · GET /api/v1/orders
subscriptions.get(id) · list(params) · cancel(id, body) · iterate(params)구독
refunds.create(body, { idempotencyKey })POST /api/v1/refunds
customers.get(id)GET /api/v1/customers/:id
customers.portal(id, { return_url })POST /api/v1/customers/:id/portal — 구매자 포털 링크 { url, expires_at }
licenses.activate(body) · validate(body) · deactivate(body) · list(params) · get(id) · revoke(id, body) · iterate(params)라이선스 키
verifyWebhook({ headers, body, secret, previousSecret?, toleranceSec = 300 })서명 확인. 통과하면 파싱한 이벤트, 아니면 PaydreamError(status 401, code: invalid_timestamp · timestamp_out_of_tolerance · missing_signature · signature_mismatch)
  • 메서드는 서버가 준 JSON을 그대로 돌려줘요. 2xx가 아니면 PaydreamError를 던지고, code는 각 절의 오류 표 값이에요. 네트워크 오류는 status: 0, code: 'network_error'.
  • 시간 제한: 시도마다 timeout(기본 30000ms). 넘으면 status: 0, code: 'timeout'. 두 번째 인자의 signal로 멈추면 network_error이고 다시 보내지 않아요.
  • 안전할 때만 다시 보내요: 조회(GET)와 idempotencyKey를 준 요청만, 429·502·503·504·네트워크 오류·시간 초과일 때 최대 maxRetries번(기본 2, 0이면 끔). Retry-After(60초 이하)를 따르고, 없으면 0.5초·1초·2초… 기다려요. 같은 키·같은 본문으로 보내니 두 번 처리되지 않아요.
  • 멱등 키가 없는 요청(구독 해지·라이선스 등)은 다시 보내지 않아요. 서버가 이미 처리했는지 알 수 없어서예요 — 직접 다시 보낼 때는 조회로 상태를 먼저 확인하세요.
  • verifyWebhook의 body는 받은 바이트 그대로(문자열 또는 Buffer)예요. previousSecret은 비밀값을 교체한 뒤 24시간 동안 옛 값을 넘길 때 써요.
// Express
app.post('/webhooks/paydream', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try { event = verifyWebhook({ headers: req.headers, body: req.body, secret: process.env.PAYDREAM_WEBHOOK_SECRET }); }
  catch (e) { return res.sendStatus(401); }
  // event.id(= X-Paydream-Idempotency-Key)로 이미 처리한 이벤트인지 먼저 확인한다
  res.sendStatus(200);
});

MCP 서버(AI 도구)

@paydream/mcp는 위 API를 MCP 도구로 내놓는 작은 서버예요. Claude Code·Claude Desktop 같은 AI 도구에 붙이면 “어제 결제된 주문 보여 줘”, “이 주문 5,000원 부분 환불해 줘” 같은 요청을 내 API 키로 처리해요. 내 컴퓨터에서 AI 도구가 직접 실행하고(stdio), 키는 내 설정에만 있어요. MCP 명세 2026-07-28과 그 이전판(2025-11-25 등, initialize 방식)을 모두 지원해요. Node 20.3 이상이 필요해요.

# Claude Code
claude mcp add paydream -e PAYDREAM_API_KEY=pd_test_… -- npx --allow-remote=root -y https://paydream.io/sdk/paydream-mcp-0.1.0.tgz
// Claude Desktop — claude_desktop_config.json
{
  "mcpServers": {
    "paydream": {
      "command": "npx",
      "args": ["-y", "https://paydream.io/sdk/paydream-mcp-0.1.0.tgz"],
      "env": { "PAYDREAM_API_KEY": "pd_test_…" }
    }
  }
}
도구하는 일
products_list내 상품 목록 (GET /api/v1/products)
checkout_session_create구매자에게 보낼 주문서 링크 (주문서 API)
orders_list · order_get주문 목록 · 주문 조회
refund_create환불. idempotency_key 필수 — 같은 환불을 다시 시도해도 두 번 되지 않아요
subscriptions_list · subscription_get · subscription_cancel구독 목록·조회·해지
customer_get · customer_portal_link구매자 조회 · 구매자 포털 링크(POST /api/v1/customers/:id/portal)
license_validate · license_revoke라이선스 키 확인 · 회수
  • 운영 키(pd_live_)에서는 돈·권리가 바뀌는 도구(refund_create·subscription_cancel·license_revoke)가 confirm: true 없이 실행되지 않아요. 처음 부르면 무엇을 할지만 돌려주고, 내가 확인한 뒤 같은 요청에 confirm: true를 더해 다시 불러야 실행돼요. 테스트 키(pd_test_)는 바로 실행해요.
  • 처음에는 테스트 키로 붙여 보세요. 운영 키를 쓸 때는 AI 도구의 “도구 실행 전 확인” 설정을 끄지 마세요.
  • API 오류와 잘못된 인자는 도구 결과(isError)로 돌아와서 AI가 읽고 고쳐 다시 부를 수 있어요. 시간 제한·다시 보내기는 SDK 규칙 그대로예요.
  • 같은 버전 주소의 내용은 바뀌지 않아요. 새 버전이 나오면 주소의 버전만 바꾸세요(paydream-mcp-latest.tgz는 늘 가장 새 것).

테스트

  • 연동을 만드는 동안은 테스트 키와 웹훅 테스트 발송으로 확인하세요.
  • 페이드림이 결제대행사의 테스트 환경으로 운영되는 동안에는 페이드림이 지정한 테스트 구매자만 결제할 수 있어요. 실제 결제로 연동을 확인해야 하면 help@paydream.io로 알려 주세요.
  • ref를 쓴다면 테스트 결제 뒤 웹훅의 sellerReference에 보낸 값이 그대로 왔는지 확인하세요.

오류

API 오류는 이런 모양이에요.

{ "error": { "code": "product_not_found", "message": "내 상품 중에 그 id 또는 주소가 없어요." } }
코드 (HTTP 상태)뜻
invalid_json (400 · 413 · 415)본문이 JSON 객체가 아니에요 (413 = 10KB 초과, 415 = Content-Type이 application/json이 아님)
invalid_ref (400)ref가 검사식에 맞지 않아요
invalid_idempotency_key (400)Idempotency-Key가 공백 없는 1~128자가 아니에요
invalid_return_url (400)return_url이 https 주소가 아니거나, 연동 설정에 등록한 내 도메인이 아니에요
unauthorized (401)API 키가 없거나 맞지 않아요
seller_not_active (403)판매자 운영이 정지된 상태예요
product_not_found (404)내 상품 중에 그 id·주소가 없어요
option_not_found (404)그 상품에 그 옵션이 없어요
order_not_found (404)내 상품의 주문 중에 그 주문번호가 없어요
product_not_live (409)지금 팔 수 없는 상품이에요 (판매 중이 아님, 판매자 인증 전, 판매 기간 밖, 수량 소진)
idempotency_conflict (409)같은 Idempotency-Key로 다른 내용의 요청이 이미 있었어요
rate_limited (429)1분에 120번을 넘었어요. 잠시 뒤 다시 보내세요

주문 목록 · 구독 · 환불 · 고객 · metadata · 라이선스 키 API의 오류 코드는 각 절의 표에 있어요. 모양은 모두 같아요.

궁금한 점은 help@paydream.io로 보내 주세요.