본문으로 건너뛰기

Getting Started

이 페이지 하나로 사전 준비 → SDK 선택 → 서버 엔드포인트 → 결제창 → Webhook 확정 → 운영 전 점검 까지 한 번에 진행할 수 있도록 안내합니다. 위에서 아래로 순서대로 따라가면 됩니다.

한 줄 요약

서버MERCHANT_API_KEY로 결제 세션을 만들고 → 브라우저가 SDK로 결제창을 열고 → 서버가 Webhook 으로 주문을 확정합니다. 브라우저 결과는 확정의 근거가 아닙니다.

클라이언트, 가맹점 서버, CROSS Pay API 사이의 전체 결제 흐름:

환불 불가 — 중복 결제 방지가 최우선

CROSS Pay 는 현재 환불 기능을 제공하지 않습니다. 같은 주문에 대해 결제가 두 번 일어나면 되돌릴 수 없으니, 다음 두 가지를 반드시 지켜주세요.

  1. 같은 주문은 같은 Idempotency-Key POST /v1/payments 를 호출합니다. 서버가 dedupe 하여 동일 결과를 반환합니다. (자세한 내용 → 단계 3)
  2. terminal 상태(PAID/FAILED/EXPIRED/CANCELLED) 이후엔 같은 checkoutId 를 재사용하지 않습니다. 새 주문은 새 POST /v1/payments 로 시작합니다 (새 Idempotency-Key 사용).

브라우저의 popup result 만 보고 "결제 안 됐겠지" 라고 판단해 재호출하면 중복 결제로 이어질 수 있습니다. 반드시 webhook 수신 또는 GET /v1/payments/{id} 서버 조회로 상태를 확인한 뒤 다음 액션을 결정하세요.

운영 전 필수 항목

중복 결제 방지체크아웃 세션 관리운영(Production) 배포 전 반드시 갖추어야 하는 항목입니다. 이 항목들 없이 운영에 올리면 안 됩니다. 상세 구현은 별도 페이지에서 다룹니다:

결제 안정성 — 중복 방지와 세션 관리

운영 전 필수
  • 같은 주문은 같은 Idempotency-Key 로 호출하고, terminal 상태 이후엔 재사용하지 않습니다.
  • 결제 세션 취소는 PENDING 일 때만 가능하며, PROCESSING 부터는 취소할 수 없습니다.
  • 결제 확정은 webhook 수신(또는 GET /v1/payments/{id})으로 하고, 브라우저 결과로 판단하지 않습니다.

1. 시작 전 준비

연동을 시작하기 전에 다음 항목을 발급·등록해 두세요. 한 항목이라도 누락되면 다음 단계에서 막힙니다.

준비 항목어디서 사용하나요?어디서 받나요?
MERCHANT_API_KEY가맹점 백엔드가 POST /v1/payments 호출 시 Authorization: Bearer ...로 사용support@crosspay.ai 발급 요청
프론트엔드 originSDK 런타임이 가맹점 origin과 checkout runtime origin을 검증할 때 사용발급 시 함께 등록 (배포된 개발/스테이징/프로덕션 origin). localhost 는 현재 지원하지 않으므로 접근 가능한 HTTPS origin 을 사용하세요.
Webhook endpoint결제 확정/실패/만료 이벤트 수신가맹점에서 HTTPS endpoint 준비 후 등록 요청
Webhook secret서명 검증용 HMAC-SHA256 key발급 시 1회만 out-of-band 전달 (시크릿 매니저에 보관)
CSP 설정CDN 스크립트와 widget iframe 허용가맹점 프론트엔드 설정

CSP 권장 설정

Content-Security-Policy:
script-src 'self' https://js.crosspay.ai;
connect-src 'self' https://api.crosspay.io;
frame-src https://widget.crosspay.ai https://checkout.crosspay.ai;
브라우저에 secret key 금지

MERCHANT_API_KEY서버 환경변수/secret manager에만 저장하고 브라우저로 전달하지 않습니다. SDK는 결제를 생성하지 않고, 서버가 만든 checkout_id로 호스팅 결제창을 여는 역할만 합니다. Idempotency-Key 는 서버 전용으로 유지됩니다.

2. 사용할 SDK 선택

브라우저 결제창은 두 가지 SDK 중 한 가지로 엽니다. 환경과 프레임워크에 맞춰 선택하세요.

항목JavaScript SDKReact SDK
패키지@nexus-cross/cross-pay-sdk@nexus-cross/cross-pay-react
권장 환경정적 HTML, Vanilla JS, 서버 렌더 페이지, 다른 프레임워크(Vue, Svelte 등)React 18/19 SPA, Next.js, Remix
API 형태loadCrossPay() + crossPay.checkout({...})<CrossPayProvider> + usePayment() hook
상태 관리직접 await / Promise 처리hook이 isLoading/error 자동 제공
SSRstub 반환 (브라우저에서만 실제 동작)Provider가 client-only 안전 처리
이런 경우 추천• 결제 페이지가 1~2개
• 마케팅 페이지에 embed
• React 의존성 없이 동작해야 함
• 이미 React 앱
• 여러 컴포넌트에서 결제 호출
• Provider로 theme/locale 일괄 관리
둘 다 같은 결제 플로우입니다

두 SDK 모두 내부적으로 동일한 CDN 런타임(cross-pay-core)을 로드합니다. React SDK는 JS SDK 를 hooks/Provider 로 감싼 얇은 wrapper 라고 보시면 됩니다.

3. 서버에 결제 세션 엔드포인트 추가

클라이언트는 CROSS Pay API를 직접 호출하지 않습니다. 결제 세션은 가맹점 서버에서 생성하며, 서버는 MERCHANT_API_KEY를 사용해 POST /v1/payments를 호출합니다.

서버에 다음과 같은 결제 세션 생성 엔드포인트를 추가합니다.

POST /checkout

이 엔드포인트는 클라이언트로부터 상품/주문 정보를 받아 CROSS Pay 결제 세션을 생성한 뒤, 클라이언트에 필요한 단 하나의 값 — API 응답의 checkout_id — 을 반환합니다. 서버가 보낸 Idempotency-Key 는 생성 중복 방지를 위한 서버 전용 값이며 클라이언트로 절대 반환하지 않습니다.

구현 예시 (Node.js)

// POST /checkout
app.post('/checkout', async (req, res) => {
const { orderId, amount, payerUid } = req.body;

// 한 번 생성해 같은 주문 재시도 시 같은 값을 재사용합니다.
// API 는 이 값을 반환하지 않습니다 — 서버가 유일한 source of truth 입니다.
const idempotencyKey = crypto.randomUUID();

const response = await fetch('https://api.crosspay.io/v1/payments', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.MERCHANT_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
currency: 'KRW',
amount: String(amount),
payer_uid: payerUid,
country: 'KR',
metadata: {
order_id: orderId,
product: { name: 'Game Pass Premium', category: 'DIGITAL_GOODS' },
},
}),
});

if (!response.ok) {
return res.status(502).json({ error: 'failed to create checkout' });
}

// payment = { id, checkout_id, checkout_url, status, currency, amount, expires_at, ... }
// 응답에 `idempotency_key` 필드는 없습니다.
const payment = await response.json();

// 클라이언트에는 checkout session id 만 전달합니다. Idempotency-Key 는
// 서버 전용으로, POST /v1/payments 생성 중복 방지에만 쓰이고 브라우저에 도달하지 않습니다.
res.json({ checkoutId: payment.checkout_id });
});

응답 형태 (가맹점 서버 → 클라이언트)

{
"checkoutId": "cs_abc123"
}
주의 사항
  • MERCHANT_API_KEY, Idempotency-Key, PG 응답 본문 전체, payment_id 등 서버 전용 값은 클라이언트에 노출하지 마세요. 클라이언트는 checkout_id만 알면 됩니다.
  • Idempotency-Key 헤더는 가맹점이 생성하는 UUID 입니다 (같은 주문 재시도 시 같은 값 사용).
  • payer_uid는 결제자마다 유니크한 값을 제공해야 합니다. 가맹점 측에서 한 결제자에 일대일로 매핑되는 안정적인 식별자(예: 내부 사용자 ID)를 사용하고, 서로 다른 결제자에 같은 값을 재사용하지 마세요.
  • country 는 optional UI hint 입니다. 응답의 checkout_url에만 ?country=XX로 붙고 DB에 저장되지 않습니다.

4. 브라우저에서 결제창 열기

이제 클라이언트에서 3단계 엔드포인트를 호출하고, 응답을 SDK에 전달해 결제창을 엽니다.

JavaScript SDK

import { loadCrossPay, CrossPaySDKError } from '@nexus-cross/cross-pay-sdk';

document.getElementById('pay').addEventListener('click', async () => {
// 1) 가맹점 서버에서 checkout session 생성
const res = await fetch('/checkout', { method: 'POST', body: JSON.stringify({...}) });
const { checkoutId } = await res.json();

// 2) SDK로 결제창 열기
try {
const crossPay = await loadCrossPay();
const result = await crossPay.checkout({
checkoutId,
appearance: { theme: 'auto' },
locale: 'ko',
embeddedWallet: true,
});
console.log(result.paymentId, result.status);
} catch (error) {
if (error instanceof CrossPaySDKError && error.code === 'USER_REJECTED') {
// 사용자가 결제창을 닫음 — 정상 분기
return;
}
// 나머지 에러는 UI에 표시
console.error(error);
}
});

React SDK

import { CrossPayProvider, CrossPaySDKError, usePayment } from '@nexus-cross/cross-pay-react';

export default function App() {
return (
<CrossPayProvider theme="auto" locale="ko">
<CheckoutButton />
</CrossPayProvider>
);
}

function CheckoutButton() {
const { requestPayment, isLoading } = usePayment();

async function handlePay() {
const res = await fetch('/checkout', { method: 'POST', body: JSON.stringify({...}) });
const { checkoutId } = await res.json();

try {
const result = await requestPayment({ checkoutId, embeddedWallet: true });
console.log(result.paymentId, result.status);
} catch (error) {
if (error instanceof CrossPaySDKError && error.code === 'USER_REJECTED') return;
throw error;
}
}

return (
<button type="button" onClick={handlePay} disabled={isLoading}>
{isLoading ? 'SDK 로드 중...' : '결제'}
</button>
);
}
결제 모드 선택
  • Overlay (popup/widget) — 가맹점 페이지를 벗어나지 않습니다. 데스크톱 결제, 페이지 내 결제수단 위젯에 적합. → Overlay 모드 가이드
  • Redirect — hosted checkout 으로 이동했다 돌아옵니다. 모바일 브라우저, 앱 WebView, 외부 PG 전환이 필요한 결제수단에 적합. → Redirect 모드 가이드

자주 발생하는 예외

상황원인처리
USER_REJECTED사용자가 결제창을 닫음에러로 처리하지 말고 UI 복구만
PAYMENT_FAILED / PAYMENT_DECLINEDPG가 결제 거절실패 안내, 재시도 유도
ScriptLoadFailedErrorCDN 차단/네트워크 실패CSP script-src 확인
ScriptLoadTimeoutError15초 내 로드 실패네트워크 점검 후 재시도

더 자세한 에러 분기는 JavaScript SDK 레퍼런스의 "에러 처리" 섹션과 Error Codes 참고.

5. Webhook 으로 결제 확정

브라우저 popup/redirect 결과만으로 주문을 확정하지 않습니다. 결제의 최종 확정은 반드시 Webhook 수신 또는 서버에서 GET /v1/payments/{id} 조회로 처리해야 합니다.

최소 구현 (Express)

import express from 'express';
import crypto from 'crypto';

const app = express();
const SECRET = process.env.CROSSPAY_WEBHOOK_SECRET;

app.post(
'/webhooks/crosspay',
express.raw({ type: 'application/json' }), // raw body 보존 필수
(req, res) => {
const id = req.get('webhook-id');
const ts = req.get('webhook-timestamp');
const sig = req.get('webhook-signature') || '';

const secretBytes = Buffer.from(SECRET, 'base64'); // secret 은 base64 인코딩됨
const expected = crypto
.createHmac('sha256', secretBytes)
.update(`${id}.${ts}.${req.body.toString('utf8')}`)
.digest();

const ok = sig.split(' ').some(pair => {
const [v, b64] = pair.split(',');
if (v !== 'v1' || !b64) return false;
const got = Buffer.from(b64, 'base64');
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
if (!ok) return res.status(401).send('invalid signature');

const event = JSON.parse(req.body.toString('utf8'));
// event.type: payment.completed | payment.failed | payment.expired | payment.cancelled
// event.data.payment: { id, status, amount, currency, metadata, ... }

// ... dedup + 주문 확정 ...
res.status(200).send('ok');
}
);
raw body 보존이 필수

JSON 파싱 후 재직렬화하면 공백·키 순서가 달라져 서명이 깨집니다. Express 는 express.raw({ type: 'application/json' }), Go 는 io.ReadAll(r.Body) 같은 방식으로 원본 바이트를 보존하세요.

다음 깊은 내용

주제어디로
이벤트 카탈로그, 페이로드 구조, 상태 머신Webhooks Overview
서명 알고리즘, replay 보호, 코드 샘플Verifying Webhooks
재시도 정책, 멱등성, dedupDelivery & Retry
프로덕션 체크리스트Webhooks 프로덕션 체크리스트
이벤트별 정확한 필드 사전API: Webhook Events 레퍼런스

6. 운영 전 체크리스트

연동을 프로덕션으로 옮기기 전 다음 항목을 점검하세요.

서버

  • MERCHANT_API_KEY, webhook secret 은 환경변수/secret manager에만 저장 — 코드 커밋 금지
  • POST /v1/payments 호출 시 Idempotency-Key 헤더로 같은 주문 재시도 안전 처리
  • webhook endpoint 는 HTTPS 사용
  • webhook 서명 검증 통과 후에만 도메인 로직 실행 (검증 실패 시 401)
  • webhook-id dedup 테이블 운영 (중복 도착 시 200 또는 409)
  • webhook 응답은 10초 이내 — 무거운 처리는 큐로 위임

클라이언트

  • MERCHANT_API_KEY가 번들/소스맵/네트워크 응답에 노출되지 않는지 확인
  • terminal 상태(PAID/FAILED/EXPIRED/CANCELLED) 이후엔 같은 checkoutId 재사용 금지
  • CSP 에 script-src https://js.crosspay.ai, frame-src https://widget.crosspay.ai 포함

확정 로직

  • Popup/redirect 결과 단독으로 주문 확정하지 않음
  • Webhook 수신 또는 GET /v1/payments/{id} 조회로 최종 확정
  • terminal 상태 도달 후 새 주문은 POST /v1/payments 로 시작

더 알아보기