판매자 안내

판매자 연동 가이드

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

연동 방법

두 가지 방법이 있어요. 어느 쪽이든 결제는 paydream.io 화면에서 끝나고, 결과는 웹훅으로 받아요.

방법이럴 때 좋아요필요한 것
결제창 링크내 사이트에 상품 소개가 이미 있고, 구매 버튼만 붙이면 될 때결제창으로 등록한 상품, 버튼 하나
주문서 API내 서버가 상품·옵션·돌아올 주소를 정해 주문서를 열어야 할 때API 키, 내 서버
페이드림은 통신판매중개자로서 주문 기록, 판매자 정보 표시, 환불 접수를 맡고, 상품의 제공과 책임은 판매자에게 있어요. 구매자는 회원으로 로그인하거나 휴대폰 문자 인증만으로 비회원 결제를 할 수 있어요.
결제가 끝났는지는 서명을 확인한 웹훅으로만 판단하세요. 구매자가 내 사이트로 돌아왔다는 사실(이동 페이지 도착, 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로 돌아오지 않아요.

주문·상품 조회

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

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
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 } ]
    }
  ]
}

상품 목록은 페이지를 나누지 않고 한 번에 와요. typeDOCUMENT(자료) · SERVICE(서비스) · EVENT(이벤트) · GOODS(제품), modepage(판매 페이지) · 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페이드림 주문번호. 결제 건을 맞추는 키예요
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

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

서명 확인

  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

테스트

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

오류

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

{ "error": { "code": "product_not_found", "message": "내 상품 중에 그 id 또는 주소가 없어요." } }
코드 (HTTP 상태)
invalid_json (400 · 413 · 415)본문이 JSON 객체가 아니에요 (413 = 10KB 초과, 415 = Content-Typeapplication/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로 보내 주세요.