sayren Docs

결제 플로우

Hosted Checkout — 주문서 생성, 결제 팝업, postMessage 수신, 결제 상태 확인

sayren 결제는 Hosted Checkout 방식입니다. 결제창은 플랫폼이 호스팅하는 별도 페이지(팝업)가 담당하므로, 쇼핑몰 프론트엔드는 PG SDK를 알 필요 없이 API가 내려주는 popupUrl을 팝업으로 엽니다.

어느 PG로 결제할지는 스토어의 결제 설정이 정합니다. 셀러가 연결한 PG(토스페이먼츠·포트원)와 라우팅 방식에 따라 팝업이 결제창을 띄우고, 카드 거절 뒤 다른 결제사로 다시 시도하는 과정도 팝업 안에서 끝납니다.

스토어가 샌드박스 모드면 결제는 테스트 결제가 됩니다. 결제창과 승인 흐름은 같지만 실제로 돈이 오가지 않고, 주문서·결제 요청· 결제 상태·주문 응답에 testPayment: true가 붙습니다. 이 값이 true면 실제로 돈이 오가지 않는다는 점을 구매자에게 표시하세요.

전체 흐름

[쇼핑몰 프론트엔드]              [sayren API]                  [결제 팝업]            [PG]
1. POST /checkout            →  가격·재고 확정 검증, 주문서(30분)
2. POST /checkout/{id}/payment→ 결제 세션 생성(pending, 10분)
                                스토어 결제 설정으로 결제창 준비
                             ←  { paymentId, pgProvider, pgParams: { popupUrl, … } }
3. window.open(빈 팝업 먼저)
   → popupUrl 로드                                             4. 결제 세션 조회
                                                               5. 결제창에서 인증    → 인증 완료
                                                               6. 결제 승인 요청
                                ← 금액 재검증 → PG 승인
                                  주문 생성(PAID)·재고 차감
                                                               7. opener.postMessage(결과)
8. message 수신 (origin·source 검증) → 완료 페이지로 이동

1. 주문서 생성

장바구니 아이템 또는 바로구매 아이템으로 체크아웃 세션(30분 유효)을 만듭니다. 이 시점의 가격과 조합별 재고가 확정 검증됩니다.

// 장바구니에서
const session = await sdk.checkout.create({ cartItemIds: ["ci_1", "ci_2"] });

// 또는 바로구매 (cartItemIds와 directItem 중 하나만)
const direct = await sdk.checkout.create({
  // optionId = 상품 상세의 variantId. 옵션 없는 상품은 생략 가능
  directItem: { productId: "prod_001", optionId: "var_001", quantity: 1 },
});

// session: {
//   checkoutId,
//   items: [{ productId, productName, optionId, optionName, quantity, unitPrice, totalPrice }],
//   amounts: { productAmount, deliveryFee, totalAmount },
//   testPayment,   // 지금 결제하면 테스트 결제인지. 최종 값은 결제 요청 응답
//   expiresAt,
// }

회원만 주문받는 스토어

스토어가 비회원 주문을 허용하지 않으면, 로그인하지 않은 구매자의 주문서 생성과 결제 요청은 403 GUEST_CHECKOUT_DISABLED입니다. 장바구니 담기와 이미 받은 비회원 주문의 조회·취소는 그대로 됩니다. 공개 설정의 guestCheckout으로 미리 알 수 있으니 주문 버튼을 로그인 안내로 바꿔 두세요.

const store = await sdk.store.get();
if (!store.guestCheckout && !isLoggedIn) {
  // "로그인하고 주문하기" — 로그인할 때 cartToken을 보내면 담아 둔 장바구니가 합쳐집니다
}

설정은 셀러 콘솔의 설정 › 스토어 정보 › 비회원 주문이나 Store API PATCH /storeguestCheckoutEnabled로 바꿉니다. 바꾼 값은 1분 안에 적용됩니다.

2. 결제 요청 → 팝업 열기

핵심 규칙: 팝업은 사용자 클릭 콜스택에서 동기적으로 먼저 열어야 브라우저 팝업 차단을 피할 수 있습니다. URL 없이 빈 팝업을 먼저 열고, API 응답을 받은 뒤 결제 페이지로 이동시키세요.

async function onPayClick(values: CheckoutFormValues) {
  // 1) 사용자 클릭 콜스택에서 동기적으로 빈 팝업을 연다 (차단 회피)
  const popup = window.open("", "sayren-payment", "width=480,height=720");

  // 2) 배송지/결제수단 확정 → 결제 세션 생성
  const params = await sdk.checkout.requestPayment(session.checkoutId, {
    shippingAddress: values.shippingAddress,
    paymentMethod: values.paymentMethod, // "CARD" | "BANK_TRANSFER" | ...
    guest: isGuest ? values.guest : undefined, // 비회원: 이름/연락처/주문 비밀번호
  });

  // 3) 열려 있는 빈 팝업을 결제 페이지로 이동
  const popupUrl = params.pgParams.popupUrl;
  if (popup) {
    popup.location.href = popupUrl;
  } else {
    showOpenPaymentButton(popupUrl); // 차단됨 → "결제창 열기" 버튼 노출
  }
}

pgParams에서 프론트엔드가 쓰는 값은 popupUrl 하나입니다. 나머지 키는 결제 팝업이 쓰는 값이라 PG에 따라 달라지고 예고 없이 바뀔 수 있습니다. pgProvider는 첫 결제 시도의 PG이며 현재 tosspayments 또는 portone입니다. testPayment는 이 결제가 테스트 결제인지를 알려 주는 최종 값입니다. 결제 id(paymentId)는 결제 상태 조회의 키이므로 결제가 끝날 때까지 보관하세요.

결제를 시작할 수 없을 때

스토어가 결제를 받을 수 없는 상태면 결제 요청이 실패합니다. 구매자가 잘못한 것이 아니므로 입력 오류와 구분해, 먼저 열어 둔 빈 팝업을 닫고 결제를 받을 수 없는 상태로 처리하세요.

응답의미
409 PAYMENT_NOT_CONFIGURED스토어에 지금 결제를 받을 수 있는 PG가 없습니다. 결제 세션을 만들지 않습니다
503 PAYMENT_PROVIDER_UNAVAILABLE사용 중인 PG가 모두 결제창을 준비하지 못했습니다. 잠시 후 같은 주문서로 다시 요청할 수 있습니다
import { ApiError } from "@sayren/storefront-sdk";

try {
  const params = await sdk.checkout.requestPayment(session.checkoutId, body);
  // …
} catch (error) {
  popup?.close();
  if (
    error instanceof ApiError &&
    (error.code === "PAYMENT_NOT_CONFIGURED" || error.code === "PAYMENT_PROVIDER_UNAVAILABLE")
  ) {
    onPaymentUnavailable(error.code); // 503은 잠시 후 같은 주문서로 다시 요청 가능
  }
}

3. postMessage 수신

결제 팝업은 결제가 끝나면 window.opener.postMessage로 결과를 보내고 스스로 닫힙니다. 카드 거절처럼 다시 결제할 수 있는 실패는 팝업 안에서 재시도를 안내하므로 부모 창에 전달되지 않고, payment:failed는 결제를 더 진행할 수 없을 때만 옵니다. 부모 창은 반드시 event.origin(popupUrl의 origin)과 event.source(직접 연 창)를 모두 검증한 뒤에만 메시지를 처리하세요.

/** 팝업 → 부모 페이로드 */
interface PaymentCompletedMessage {
  type: "payment:completed";
  paymentId: string;
  orderId: string;
  approvedAt: string | null;
}
interface PaymentFailedMessage {
  type: "payment:failed";
  paymentId?: string;
  code?: string;
  message?: string;
}

const popupOrigin = new URL(popupUrl).origin;
window.addEventListener("message", (event) => {
  if (event.origin !== popupOrigin || event.source !== popup) return; // 필수 검증
  if (event.data?.type === "payment:completed") onCompleted(event.data.orderId);
  if (event.data?.type === "payment:failed") onFailed(event.data.code);
});

팝업 쪽도 사전에 허용된 부모 origin으로만 발신합니다 — 와일드카드(*) 수신에 의존하지 마세요.

4. 결제 상태 확인

결제창이 결제사·카드사 페이지를 거치는 동안 부모 창과의 연결이 끊길 수 있습니다. 그러면 결과 메시지가 오지 않고, 팝업이 아직 열려 있는데도 부모 창에서는 popup.closedtrue로 보일 수 있습니다. 그래서 창이 닫힌 것처럼 보여도 결제가 끝났다고 판단하지 말고 GET /payments/{paymentId}로 결제 상태를 확인하세요. 인증 없이 호출할 수 있고, 응답에 개인정보는 없습니다.

const payment = await sdk.payments.getStatus(params.paymentId);
// { paymentId, status, orderId, testPayment, expiresAt }
status의미할 일
pending결제 대기. 구매자가 아직 결제창에서 결제 중일 수 있음계속 확인. 결제가 끝났다고 판단하지 않음
processing승인 결과 확인 중확인 중으로 표시하고 계속 확인. 새 결제를 시작하지 않음
completed결제 완료, 주문 생성orderId로 완료 처리
failed · expired결제가 끝나지 않음실패로 처리. 새 결제를 시작할 수 있음

status는 문자열이고 값이 추가될 수 있습니다. 모르는 값은 processing처럼 다루세요. Storefront SDK의 PAYMENT_STATUS_VALUES가 현재 값 목록입니다. orderIdcompleted일 때만 있고, testPayment는 완료된 결제면 주문의 값, 아니면 결제 요청 시점의 값입니다.

처리 규칙은 다음과 같습니다.

  • 확인 시점 — 결과 메시지 없이 창이 닫힌 것처럼 보이면 2초 간격으로 확인을 시작합니다. 결과 메시지를 받았더라도 주문번호는 결제 상태 응답의 값을 우선합니다.
  • pending — 결제를 취소했다고 확정하지 않습니다. 확인을 계속하면서 구매자가 원하면 새 결제를 시작할 수 있게 합니다. 새 결제는 확인을 멈춘 뒤에 요청합니다. 같은 주문서는 한 번만 결제되며, 이전 결제와 새 결제가 모두 승인되면 나중에 끝난 결제를 서버가 자동으로 취소합니다.
  • 상한 — 확인은 expiresAt까지 합니다. 그 시각이 지나면 한 번 더 조회해 최종 상태를 정합니다. 결제 대기로 남은 결제는 이때 expired로 보고됩니다. 그래도 processing이면 승인 결과가 확정되지 않은 것이므로 실패로 처리하지 말고, 나중에 주문 내역에서 확인하게 합니다. 사용자가 페이지를 떠나면 확인을 멈춥니다.
  • 테스트 결제testPaymenttrue면 완료 처리 때 테스트 결제임을 함께 표시합니다.
async function watchPayment(paymentId: string, signal: AbortSignal) {
  let payment = await sdk.payments.getStatus(paymentId);
  while (!signal.aborted && Date.now() < Date.parse(payment.expiresAt)) {
    if (payment.status === "completed" || payment.status === "failed" || payment.status === "expired") break;
    await new Promise((resolve) => setTimeout(resolve, 2000)); // pending·processing·모르는 값
    payment = await sdk.payments.getStatus(paymentId);
  }
  if (signal.aborted) return;
  if (payment.status !== "completed" && payment.status !== "failed") {
    payment = await sdk.payments.getStatus(paymentId); // 상한에서 한 번 더 확인
  }
  switch (payment.status) {
    case "completed":
      return onCompleted(payment.orderId, payment.testPayment);
    case "failed":
    case "expired":
      return onFailed();
    default:
      return onStillProcessing(); // 승인 결과 미확정. 주문 내역에서 다시 확인
  }
}

결제 API 규칙

결제 관련 API가 보장하는 동작입니다.

  1. 금액 재검증 — 승인 전에 결제 금액을 주문서 금액과 대조하고, PG가 승인한 금액도 결제 금액과 대조합니다. PG 승인 금액이 다르면 결제를 자동 취소하고 409 PG_AMOUNT_MISMATCH를 반환합니다.
  2. 상태는 단방향 — 결제 세션은 pending → completed | failed | expired로만 끝납니다. 카드 거절처럼 승인되지 않은 것이 확정된 실패는 세션을 끝내지 않습니다. 세션은 pending으로 남고 같은 세션에서 다시 결제할 수 있습니다. 이미 끝난 세션에 승인을 요청하면 409 ALREADY_PROCESSED.
  3. 승인은 멱등 — 같은 paymentId + pgToken 재호출은 저장된 결과를 그대로 반환합니다. 다른 pgToken이면 409 ALREADY_COMPLETED_WITH_DIFFERENT_KEY.
  4. 현재 시도만 승인 — 결제 세션 하나에 결제 시도가 여러 번 생길 수 있습니다(다른 결제사로 재시도). 승인 요청의 attemptId는 결제창이 돌려준 주문번호이고, 생략하면 현재 시도를 승인합니다. 지난 시도를 지정하면 409 ATTEMPT_NOT_CURRENT이며, 그 시도가 이미 결제됐다면 서버가 취소합니다.
  5. 재고 경합 — 승인 중 재고가 소진되면 409 STOCK_DEPLETED와 함께 결제가 자동 취소됩니다.
  6. 만료 — 주문서는 30분, 결제 세션은 10분 뒤 만료됩니다. 만료 후 요청은 각각 409 CHECKOUT_EXPIRED, 409 PAYMENT_EXPIRED.

결제 승인 응답

결제 팝업을 쓰면 승인은 팝업이 처리합니다. POST /payments/{paymentId}/confirm을 직접 호출한다면 응답에 따라 다음처럼 처리하세요.

응답세션처리
200completed주문 완료
402(PG 거절 코드) · 402 PAYMENT_NOT_APPROVED · 502 PG_REQUEST_FAILED · 409 LATE_APPROVAL_CANCELEDpending같은 세션에서 다시 결제할 수 있습니다
409 PAYMENT_RESULT_UNKNOWN · 409 PAYMENT_IN_PROGRESS처리 중승인을 다시 요청하지 말고 잠시 후 주문 내역을 확인합니다
409 ALREADY_COMPLETED_WITH_DIFFERENT_KEYcompleted다른 승인으로 이미 완료됐습니다. 주문 내역을 확인합니다
409 STOCK_DEPLETED · ALREADY_PAID · PG_AMOUNT_MISMATCH · AMOUNT_MISMATCH · CHECKOUT_EXPIRED · CHECKOUT_NOT_FOUND · PG_ENVIRONMENT_MISMATCHfailed승인된 결제는 자동 취소됩니다. 주문서부터 다시 시작합니다
409 ALREADY_PROCESSED · PAYMENT_EXPIRED · PAYMENT_SESSION_OUTDATED · ATTEMPT_NOT_CURRENT · INVALID_ATTEMPT_STATE새 결제 요청이 필요합니다
503 PG_CREDENTIALS_UNAVAILABLE · 503 PG_MERCHANT_MISMATCHpending스토어 결제 설정 문제로 이 결제사로는 승인할 수 없습니다. 다른 결제사로 다시 결제하거나 결제를 받을 수 없는 상태로 처리합니다

포트원 결제에서는 결제창에서 이미 결제가 끝났을 수 있어, 스토어 결제 설정 문제로 승인하지 못한 경우에도 503 대신 409 PAYMENT_RESULT_UNKNOWN을 반환합니다. 처리 방법은 PAYMENT_RESULT_UNKNOWN과 같습니다.

관련 API 레퍼런스

On this page