연동 방법
세 가지 방법이 있어요. 어느 쪽이든 결제는 paydream.io 주문서에서 끝나고(임베드는 그 주문서를 내 사이트 위의 작은 창으로 열어요), 결과는 웹훅으로 받아요.
| 방법 | 이럴 때 좋아요 | 필요한 것 |
|---|---|---|
| 결제창 링크 | 내 사이트에 상품 소개가 이미 있고, 구매 버튼만 붙이면 될 때 | 결제창으로 등록한 상품, 버튼 하나 |
| 주문서 API | 내 서버가 상품·옵션·돌아올 주소를 정해 주문서를 열어야 할 때 | API 키, 내 서버 |
| 임베드 | 구매자가 내 사이트를 떠나지 않고 결제하게 하고 싶을 때(주문서 API 위에 스크립트 한 줄) | 주문서 API + embed.js |
결제창 링크
상품을 등록할 때 결제창을 고르면 결제만 받는 짧은 화면이 만들어져요. 옵션은 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)
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와 같아요. |
status | pending(결제 전) · processing(승인 중) · paid(결제 완료) · refunding(취소 중) · refunded(전액 환불) · failed(실패). 일부만 환불되면 paid로 남고 refund_amount가 늘어요. |
amount · refund_amount | 결제 금액과 지금까지 환불된 금액 합계(원) |
kind | once 일반결제 · 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은 미포함 |
status | paid · 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
}
| 필드 | 설명 |
|---|---|
status | active · 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 = 바로 끝나요(두 이벤트가 곧이어 와요) |
해지 본문 reason | 10~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 (선택) | 상세 사유. 구매자 메일에 실려요 |
응답 status | refunded 결제사 취소까지 끝남 · 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"
}
| 필드 | 설명 |
|---|---|
kind | member 회원 · 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 | 페이드림 주문번호. 결제 건을 맞추는 키예요 |
sellerReference | ref로 넘긴 내 참조값 (넘기지 않았거나 버려졌으면 없음) |
buyer | type(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(항상 []), 소계 |
pricing | currency(KRW), 할인 전 총액, 할인 합계, shippingFee(제품만, 최종 금액에 포함), finalAmount(실제 결제 금액) |
paymentMethod | type(CARD · 무료면 FREE), 카드사명, 일부를 가린 카드번호 |
shipping | 제품만. address · streetAddress · detailAddress · deliveryRequest |
questionAnswers[] | 상품에 설문을 넣었으면 질문·유형·필수 여부·답·순서 |
payment.purchasedAt | 결제 시각 |
이벤트별로 더해지는 내용
| 이벤트 | 결제 완료와 다른 점 |
|---|---|
payment.cancel_requested | cancelRequest: reason(code: 단순 변심이면 CHANGED_MIND, 그 밖은 ETC — 사유는 label에), 구매자가 쓴 detailReason(10~500자), requestedBy(BUYER), requestedAt. sellerReference는 없어요 |
payment.refunded | refund: 이번 환불 amount, currency, partialRefund, cancelledBy(BUYER 구매자가 바로 취소했거나 구매자의 환불 요청을 판매자·페이드림이 승인 · SELLER 판매자가 직접 환불 · ADMIN 페이드림이 직접 환불), reason, refundedAt. pricing은 원래 결제 금액 그대로이고, 누적 환불액은 받는 쪽에서 더하세요. sellerReference·paymentMethod·questionAnswers는 없어요 |
subscription_payment.completed | subscription: billingReason(INITIAL 첫 결제 · RENEWAL 갱신), currentRound, nextBillingDate, status, activatedAt, lastBillingSucceededAt, billingCycleMonths(1~12). paymentMethod·questionAnswers는 첫 결제에만 |
subscription_payment.refunded | refund(위와 같음)와 subscription(refundedRound 포함). 이용 권한은 subscription.terminated로 판단하세요 |
subscription_payment.failed | failure: attemptNumber, isFinal, reason, failedAt, 다시 시도할 때만 nextRetryAt(하루 뒤). 3번째 실패가 마지막(isFinal: true)이고 유예 기간 없이 바로 끝나요(gracePeriodEndsAt = 실패 시각, 곧이어 subscription.terminated). merchantUid는 시도마다 새 값이니 정기결제는 sellerReference로 이으세요 |
subscription.cancel_requested | subscription(cancelledAt·price 포함)과 cancelRequest(reason.code: NOT_USED · MISSING_FEATURES · TOO_EXPENSIVE · OTHER, 판매자가 해지하면 reason은 빈 객체, requestedBy: BUYER · SELLER). 판매자 해지면 cancellationSource와 이용 가능한 마지막 시각 serviceEndsAt |
subscription.terminated | termination: 종료 사유 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를 함께 저장해 두면 편해요.
서명 확인
- 본문을 받은 바이트 그대로 읽어요. 파싱한 뒤 다시 문자열로 만들면 서명이 맞지 않아요.
X-Paydream-Timestamp값과 본문을 점으로 이어{timestamp}.{본문}을 만들고, 서명 비밀값으로 HMAC-SHA256을 계산해 16진수로 바꿔요.- 그 값이
X-Paydream-Signature또는X-Paydream-Signature-Previous와 같은지 비교해요. 길이를 먼저 확인하고, 같은 길이면 상수 시간 비교 함수로 비교하세요. - timestamp가 지금 시각과 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로 보내 주세요.