CP-Pay 단건 결제 (Direct)
/payments/direct/checkout + /payments/direct/confirm 2단계 흐름으로 단건 결제를 받습니다. TossPayments SDK 위젯을 자기 프론트에 통합하는 패턴입니다.
결제 흐름
[1] App FE → POST /payments/direct/checkout (with api_key)
[2] CP-Pay → { client_key, order_id, amount, success_url, fail_url }
[3] App FE → TossPayments SDK로 결제창 띄움
loadTossPayments(client_key).requestPayment({ orderId, amount, successUrl, failUrl, orderName })
[4] User → 카드 입력 → 결제 완료
[5] Toss → redirect → success_url?paymentKey=...&orderId=...&amount=...
[6] App BE → POST /payments/direct/confirm (payment_key, order_id, amount)
[7] CP-Pay → 결제 확정 → 응답Step 1 — 백엔드: checkout 세션 생성
POST
https://pay.cp-platform.com/payments/direct/checkoutexport async function POST(req: Request) {
// ❶ 본인 백엔드 세션에서 인증된 user → 본인 DB profiles.cp_user_id 매핑
const me = await getSession(req); // 본인 인증 로직
if (!me?.cp_user_id) return new Response("unauthorized", { status: 401 });
const { productName, amount } = await req.json();
const res = await fetch("https://pay.cp-platform.com/payments/direct/checkout", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.CP_PAY_API_KEY!,
},
body: JSON.stringify({
user_id: me.cp_user_id, // ❷ 인증된 user, 클라이언트 입력 금지
product_name: productName,
amount, // 정수 (KRW: 원, USD: 센트)
currency: "KRW",
success_url: "https://your-app.com/payment/success",
fail_url: "https://your-app.com/payment/fail",
metadata: { product_id: "sku_123" },
}),
});
const data = await res.json();
// ❸ 응답 검증 — HTTP 200 + status="ready" 둘 다 확인
// cp-pay 는 toss 미구성 등 비치명적 오류를 200 + status="error" 로 반환
if (!res.ok || data.status !== "ready") {
console.error("[cp-pay checkout]", res.status, data);
return Response.json(
{ error: data.message || "결제 세션 생성에 실패했습니다." },
{ status: res.status === 200 ? 502 : res.status },
);
}
// ❹ 본인 DB 에 주문 기록 — confirm 단계에서 amount 변조 검증 + 멱등성에 필요
await db.orders.insert({
cp_order_id: data.order_id,
user_id: me.id,
amount: data.amount,
status: "pending",
});
return Response.json(data);
}{
"status": "ready",
"client_key": "test_ck_xxx",
"order_id": "direct_yourapp_abc123",
"order_name": "프리미엄 플랜",
"amount": 9900,
"currency": "KRW",
"success_url": "https://your-app.com/payment/success",
"fail_url": "https://your-app.com/payment/fail",
"is_foreign": false
}{ "status": "error", "message": "결제 서비스가 구성되지 않았습니다. 관리자에게 문의해주세요." }Step 2 — 프론트엔드: Toss 위젯
"use client";
import { loadTossPayments } from "@tosspayments/payment-sdk";
async function startCheckout(productName: string, amount: number) {
const r = await fetch("/api/checkout/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productName, amount }),
});
if (!r.ok) {
const { error } = await r.json().catch(() => ({}));
return alert(error ?? "결제 시작에 실패했습니다.");
}
const session = await r.json();
try {
const tp = await loadTossPayments(session.client_key);
await tp.requestPayment("카드", {
orderId: session.order_id,
amount: session.amount,
orderName: session.order_name,
successUrl: session.success_url,
failUrl: session.fail_url,
});
// 정상이면 toss → success_url 로 redirect, 아래 라인은 안 실행됨
} catch (err: any) {
// Toss SDK reject — 사용자 취소 / 카드사 거절
if (err?.code === "USER_CANCEL" || err?.code === "PAY_PROCESS_CANCELED") return;
console.error("[toss]", err?.code, err?.message);
alert(err?.message ?? "결제 처리 중 오류가 발생했습니다.");
}
}Step 3 — 프론트엔드: success / fail 페이지
토스가 결제 완료 후 success_url 로 redirect 하면 query 에 paymentKey/orderId/amount 가 포함됩니다. 그 값을 본인 백엔드로 보내서 확정합니다.
"use client";
import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "next/navigation";
export default function Success() {
const sp = useSearchParams();
const called = useRef(false); // React Strict Mode 중복 방지
const [state, setState] = useState<"loading" | "ok" | "err">("loading");
const [msg, setMsg] = useState("");
useEffect(() => {
if (called.current) return;
called.current = true;
const paymentKey = sp.get("paymentKey");
const orderId = sp.get("orderId");
const amount = Number(sp.get("amount") ?? "0");
if (!paymentKey || !orderId || !amount) {
setState("err"); setMsg("결제 정보가 누락되었습니다.");
return;
}
fetch("/api/checkout/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paymentKey, orderId, amount }),
})
.then(async (r) => {
if (!r.ok) throw new Error((await r.json()).error || "결제 승인 실패");
return r.json();
})
.then(() => setState("ok"))
.catch((e) => { setState("err"); setMsg(e.message); });
}, [sp]);
if (state === "loading") return <p>결제 확정 중…</p>;
if (state === "ok") return <p>결제가 완료되었습니다.</p>;
return <p>실패: {msg}</p>;
}fail_url 페이지는 토스가 code/message/orderId 를 query 로 넘기므로 보여주는 정적 페이지로 충분:
"use client";
import { useSearchParams } from "next/navigation";
export default function Fail() {
const sp = useSearchParams();
return <p>결제 실패 ({sp.get("code")}): {sp.get("message")}</p>;
}Step 4 — 백엔드: 결제 확정
POST
https://pay.cp-platform.com/payments/direct/confirmexport async function POST(req: Request) {
const me = await getSession(req);
if (!me?.id) return new Response("unauthorized", { status: 401 });
const { paymentKey, orderId, amount } = await req.json();
// ❶ 본인 DB 의 order 검증 — Step 1 에서 저장한 row 와 매칭
const order = await db.orders.findOne({ cp_order_id: orderId });
if (!order || order.user_id !== me.id) {
return Response.json({ error: "주문을 찾을 수 없습니다." }, { status: 404 });
}
if (order.amount !== amount) {
// 클라이언트가 보낸 amount 와 DB 에 박아둔 amount 가 다르면 변조 의심
return Response.json({ error: "금액이 일치하지 않습니다." }, { status: 400 });
}
if (order.status === "succeeded") {
return Response.json({ ok: true, duplicate: true }); // 멱등
}
// ❷ cp-pay confirm 호출
const res = await fetch("https://pay.cp-platform.com/payments/direct/confirm", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.CP_PAY_API_KEY!,
},
body: JSON.stringify({ payment_key: paymentKey, order_id: orderId, amount }),
});
const data = await res.json();
// ❸ 응답 검증
if (!res.ok || data.status !== "success") {
await db.orders.update({ cp_order_id: orderId }, { status: "failed" });
console.error("[cp-pay confirm]", res.status, data);
return Response.json(
{ error: data.message ?? "결제 승인에 실패했습니다." },
{ status: 400 },
);
}
// ❹ 본인 비즈니스 로직 — 포인트 충전, 라이선스 발급, 콘텐츠 잠금 해제 등
await db.orders.update({ cp_order_id: orderId }, {
status: "succeeded",
payment_key: data.payment_key,
receipt_url: data.receipt_url,
});
await grantBenefit(me.id, order);
return Response.json({ ok: true, receipt_url: data.receipt_url });
}Sandbox
현재 운영 CP-Pay의 TOSS_SECRET_KEY 가 test_ prefix면 자동으로 테스트 모드입니다. Toss 테스트 카드(5570-2330-1052-7763 등)로 실결제 없이 통합 검증이 가능합니다.
운영 전환 시 Toss 라이브 키로 교체 후 docker compose up -d cp-pay 재기동만 하면 됩니다. 우리 쪽 코드 변경 없음.
checkout_url 발급 endpoint는 없습니다
토스페이먼츠는 SDK 위젯 모델이라 백엔드가 결제창 URL을 발급하지 않습니다. 응답에는 SDK 파라미터(client_key, order_id, amount, success_url, fail_url) 만 들어 있습니다.
자기 백엔드만으로 결제를 받고 싶다면 (즉, SDK 없이 redirect URL 만으로) cp-platform에 별도 hosted checkout 페이지 추가가 필요합니다 — 표준 패턴은 아니므로 요청 시 검토합니다.