연동 방법
두 가지 방법이 있어요. 어느 쪽이든 결제는 paydream.io 화면에서 끝나고, 결과는 웹훅으로 받아요.
| 방법 | 이럴 때 좋아요 | 필요한 것 |
|---|---|---|
| 결제창 링크 | 내 사이트에 상품 소개가 이미 있고, 구매 버튼만 붙이면 될 때 | 결제창으로 등록한 상품, 버튼 하나 |
| 주문서 API | 내 서버가 상품·옵션·돌아올 주소를 정해 주문서를 열어야 할 때 | 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로 돌아오지 않아요.
주문·상품 조회
내 상품의 주문만 조회돼요. 웹훅을 놓쳤을 때 확인용으로 써요.
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 |
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(결제창)예요.
웹훅
판매 관리 > 연동에서 웹훅 주소(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 | 정기결제가 실제로 끝남. 이용 권한을 막는 기준이에요 |
요청 모양
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으로 판단하세요.
결제 완료 내용 (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 |
테스트
- 연동을 만드는 동안은 테스트 키와 웹훅 테스트 발송으로 확인하세요.
- 페이드림이 결제대행사의 테스트 환경으로 운영되는 동안에는 페이드림이 지정한 테스트 구매자만 결제할 수 있어요. 실제 결제로 연동을 확인해야 하면 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번을 넘었어요. 잠시 뒤 다시 보내세요 |
궁금한 점은 help@paydream.io로 보내 주세요.