sayren Docs

SDK 없이 결제 연동

결제 주소(payUrl)를 직접 열고 결제 상태 API로 결과를 확정하는 방식

SDK 없이 결제 주소(payUrl)를 열고 결제 상태 API로 결과를 확정합니다. SDK를 쓸 수 없는 환경(다른 언어·프레임워크)용이고, 대부분은 결제 연동의 @sayren/storefront-sdk/payments로 충분합니다.

세 개의 API만 씁니다

메서드·경로하는 일
POST /checkout/{checkoutId}/payment결제를 시작하고 결제 주소(payUrl)를 받습니다
POST /payments/{paymentId}/attempts다른 옵션으로 다시 시도하고 새 payUrl을 받습니다
GET /payments/{paymentId}결과를 확정합니다(인증 없이 결제 id로 조회)

결제 승인 API는 공개되지 않습니다. PG 승인과 실패 처리는 sayren 결제 화면이 합니다. 스토어프론트는 PG 승인 키(paymentKey·txId)를 다루지 않습니다. 예전의 POST /payments/{paymentId}/confirm과 실패 보고 API는 없어졌습니다.

흐름

  1. 결제 시작 — option·returnUrl·shippingAddress(비회원은 guest)를 보내고 payUrl을 받습니다.
  2. 결제창 열기 — payUrl을 팝업(mode=popup 덧붙임)이나 현재 탭으로 엽니다. 결제 화면이 PG 결제와 승인을 끝냅니다.
  3. 결과 신호 받기 — 팝업이면 메시지가 오고, 리다이렉트면 구매자가 returnUrl로 돌아옵니다.
  4. 결과 확정 — GET /payments/{paymentId}의 result로 화면을 정합니다. 신호는 그대로 믿지 않습니다.
  5. 재시도 — 실패·취소면 POST /payments/{paymentId}/attempts로 새 payUrl을 받아 2번부터 다시 합니다.

1. 결제 시작

const res = await fetch(`${API}/checkout/${checkoutId}/payment`, {
  method: "POST",
  headers: { "content-type": "application/json", "X-Store-Code": STORE_CODE },
  body: JSON.stringify({
    option: { pg: "tosspayments", method: "EASY_PAY", provider: "NAVERPAY" },
    returnUrl: `${location.origin}/checkout/return`,
    shippingAddress: { receiverName, phone, zipCode, address1 },
    // guest: { name, phone, email, orderPassword }  // 비회원 주문
  }),
});
const start = (await res.json()).data;
// { paymentId, attemptId, option, payUrl, expiresAt, testPayment, amounts, delivery }
  • returnUrl은 https 절대 주소입니다. 셀러가 설정 › 결제의 결제 도메인을 등록했으면 그 안이어야 합니다(400 RETURN_URL_NOT_ALLOWED). 테스트 결제는 localhost를 항상 허용합니다.
  • 팝업 결제라면 returnUrl을 결제를 시작한 페이지와 같은 origin으로 두십시오. 결과 메시지는 복귀 주소의 origin으로 가서, 다르면 상태 조회로만 결과를 압니다.
  • 시작 실패 코드는 결제를 시작할 수 없을 때에 있습니다.

확정 금액은 이 응답에 있습니다

주문서 금액에는 제주·도서산간 추가 배송비가 빠져 있습니다. 결제창 금액은 결제 시작이 보낸 배송지로 확정되고 응답의 amounts(totalAmount)와 delivery(remoteSurcharge·remoteAreaLabel)에 있습니다. 결제창을 열기 전에 이 값으로 화면을 갱신하십시오. 입력 중 미리보기는 POST /checkout/{checkoutId}/delivery-quote에 { "zipCode": "63000" }을 보냅니다(주문서를 바꾸지 않음).

2. 결제창 열기

payUrl에는 attemptId 쿼리가 이미 있습니다. 지우거나 바꾸지 마십시오.

// 팝업 — mode=popup을 덧붙이고 클릭 콜스택 안에서 연다
const popup = window.open(`${start.payUrl}&mode=popup`, "sayren-payment", "width=500,height=720");

// 리다이렉트 — mode를 붙이지 않는다
location.assign(start.payUrl);
  • 모바일은 리다이렉트를 쓰십시오. 앱 전환으로 팝업 연결이 끊깁니다.
  • window.open이 null이면 팝업 차단이니 리다이렉트로 내려가십시오.
  • 결제 시작이 느린 SSR 앱은 클릭 시점에 about:blank 창을 먼저 열고, 응답이 오면 그 창 주소를 payUrl로 바꾸십시오.

3. 결과 신호 받기

팝업 — message 이벤트

window.addEventListener("message", async (event) => {
  if (event.origin !== new URL(start.payUrl).origin) return; // 결제 화면이 보냈는가
  const data = event.data;
  if (data?.type !== "sayren:payment" || data.paymentId !== start.paymentId) return; // 내 결제인가
  settle(await getStatus(start.paymentId)); // 결과·실패 사유는 상태 조회로
});
  • 메시지는 { type: "sayren:payment", paymentId, result, orderId? }이고 실패 코드·사유가 없습니다. 누구나 보낼 수 있으니 판정과 안내 문구는 상태 조회로 정하십시오.
  • 팝업이 메시지 없이 닫히는 경우도 처리하십시오. popup.closed를 0.5초 간격으로 보고, 닫혔으면 상태를 조회합니다. PENDING이면 결제 전에 닫은 것이니 취소로 다룹니다.

리다이렉트 — 복귀 주소

returnUrl에 쿼리 두 개가 붙어 돌아옵니다. 상점이 넣어 둔 기존 쿼리는 보존됩니다.

쿼리의미
sayrenPaymentId결제 id. 이 값으로 상태를 조회합니다
sayrenResult참고값. 화면 분기에 쓰지 마십시오
const paymentId = new URLSearchParams(location.search).get("sayrenPaymentId");
if (!paymentId) return location.replace("/cart"); // 결제 정보 없음
settle(await getStatus(paymentId));

4. 결과 확정

async function getStatus(paymentId) {
  const res = await fetch(`${API}/payments/${paymentId}`, { headers: { "X-Store-Code": STORE_CODE } });
  return (await res.json()).data;
}
필드의미
result화면 분기용 결과(아래 표)
statuspending · processing · completed · failed · expired
orderId완료됐으면 주문 번호, 아니면 null
lastFailure현재 시도가 실패·취소로 끝났으면 { attemptId, category, code, message, optionUnavailable }. 분기는 category, 안내는 message
testPayment · expiresAt테스트 결제 여부, 결제 만료 시각
result처리
COMPLETEDorderId로 완료 화면
CANCELED · FAILEDlastFailure.message를 보여 주고 다른 옵션으로 재시도
EXPIRED결제가 만료됨. 주문서부터 다시
PROCESSING확인 중. 2초 간격으로 다시 조회하고 다시 결제하게 하지 마십시오
PENDING결제 전. 팝업이 닫혔다면 취소로 다룹니다

result·status는 값이 늘 수 있습니다. 모르는 값은 PROCESSING처럼 다루고 계속 조회하십시오(대기 코드 예).

lastFailure.optionUnavailable이 true면 상점의 PG 설정 문제처럼 구매자가 고칠 수 없는 실패입니다. 같은 결제 옵션으로 다시 시도해도 실패하므로 그 옵션을 고르지 못하게 하고 다른 결제수단을 고르게 하십시오. 이때 message는 「이 결제수단은 지금 사용할 수 없습니다. 다른 결제수단을 선택하십시오」입니다. PG가 준 원문 사유는 상점 플랫폼 결제 내역에서 볼 수 있습니다.

5. 다른 옵션으로 다시 시도

const res = await fetch(`${API}/payments/${paymentId}/attempts`, {
  method: "POST",
  headers: { "content-type": "application/json", "X-Store-Code": STORE_CODE },
  body: JSON.stringify({ option: { pg: "portone", method: "CARD" } }), // returnUrl 생략 시 처음 값
});
const next = (await res.json()).data; // 새 payUrl

승인·확인 중이면 409 PAYMENT_IN_PROGRESS, 만료됐으면 409 PAYMENT_EXPIRED입니다. 결제창을 닫고 돌아오지 않은 이전 시도는 버려집니다.

지켜야 할 것

  1. 판정의 근거는 상태 조회 하나 — 메시지와 복귀 쿼리는 조작될 수 있는 신호입니다.
  2. 메시지의 origin과 paymentId 확인 — event.origin이 payUrl의 origin이고 paymentId가 내 결제여야 합니다.
  3. PROCESSING은 실패가 아님 — 확정 전에 다시 결제하게 하면 이중 결제가 납니다.
  4. 결제 id는 추측 불가 — 상태 조회는 인증이 없지만 결제 id가 난수라 시작한 구매자만 압니다. 개인정보·PG 값은 응답에 없습니다.
  5. 복귀 화면은 여러 번 열려도 안전 — 조회만 하므로 결과가 바뀌지 않습니다.
  6. 만료 — 주문서 30분, 결제 10분. 이후 409 CHECKOUT_EXPIRED, 409 PAYMENT_EXPIRED입니다.

관련 API 레퍼런스

다음 단계

이 페이지 목차