CP-Pay 구독 결제 (Subscriptions)
/subscriptions/checkout/external + /subscriptions/confirm/external 2단계 흐름으로 외부 서비스에서 CP 구독을 받습니다. 구독 관리 (해지·재개) 는 CP Platform 마이페이지에서 통합 처리 되므로 외부 서비스가 직접 구현할 필요 없습니다.
결제 흐름
[1] App FE → POST /subscriptions/checkout/external (with api_key)
[2] CP-Pay → { client_key, order_id, amount, success_url, fail_url, plan_code }
[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 /subscriptions/confirm/external (payment_key, order_id, amount)
[7] CP-Pay → 구독 활성화 + 라이선스 발급 → 응답단건 결제와 차이점:
- 자동 라이선스 발급 — confirm 성공 시
license_type이 CP-Auth 에 자동 set, CP-Notify 쿼터도 Pro로 승급 - 구독 관리는 CP 가 처리 — 사용자가 구독 해지/재개 하려면
cp-platform.com/mypage/subscription으로 보내세요 - order_id 형식 —
extsub_{service_id}_{user_id}_{random}으로 구분되어 confirm 시 서비스 식별
Step 1 — 백엔드: checkout 세션 생성
POST
https://pay.cp-platform.com/subscriptions/checkout/externalexport 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 { planCode } = await req.json(); // "starter" | "standard" | "pro" | "enterprise"
const res = await fetch("https://pay.cp-platform.com/subscriptions/checkout/external", {
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, 클라이언트 입력 금지
plan_code: planCode, // 소문자/숫자/_ 만 허용
success_url: "https://your-app.com/subscription/success",
fail_url: "https://your-app.com/subscription/fail",
}),
});
const data = await res.json();
// ❸ 응답 검증 — HTTP 200 + status="ready" 둘 다 확인
if (!res.ok || data.status !== "ready") {
console.error("[cp-pay sub checkout]", res.status, data);
return Response.json(
{ error: data.message || "구독 세션 생성에 실패했습니다." },
{ status: res.status === 200 ? 502 : res.status },
);
}
// ❹ 본인 DB 에 주문 기록 — confirm 단계의 amount 변조 검증 + 멱등성에 필요
await db.subscriptionOrders.insert({
cp_order_id: data.order_id,
user_id: me.id,
plan_code: data.plan_code,
amount: data.amount,
status: "pending",
});
return Response.json(data);
}{
"status": "ready",
"client_key": "test_ck_xxx",
"order_id": "extsub_yourapp_abc123_xyz789",
"order_name": "CP Pro 구독",
"amount": 19900,
"currency": "KRW",
"success_url": "https://your-app.com/subscription/success",
"fail_url": "https://your-app.com/subscription/fail",
"plan_code": "pro",
"plan_name": "Pro"
}{ "status": "error", "message": "결제 서비스가 구성되지 않았습니다. 관리자에게 문의해주세요." }{ "detail": "이미 활성화된 구독이 있습니다." }Step 2 — 프론트엔드: Toss 위젯
단건 결제와 100% 동일한 호출 패턴입니다.
"use client";
import { loadTossPayments } from "@tosspayments/payment-sdk";
async function startSubscription(planCode: string) {
const r = await fetch("/api/subscription/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ planCode }),
});
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) {
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/subscription/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 로 넘기므로 정적 페이지로 충분.
Step 4 — 백엔드: 결제 확정
POST
https://pay.cp-platform.com/subscriptions/confirm/externalexport 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 검증
const order = await db.subscriptionOrders.findOne({ cp_order_id: orderId });
if (!order || order.user_id !== me.id) {
return Response.json({ error: "주문을 찾을 수 없습니다." }, { status: 404 });
}
if (order.amount !== amount) {
// 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/subscriptions/confirm/external", {
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.subscriptionOrders.update({ cp_order_id: orderId }, { status: "failed" });
console.error("[cp-pay sub confirm]", res.status, data);
return Response.json(
{ error: data.message ?? "구독 승인에 실패했습니다." },
{ status: 400 },
);
}
// ❸ 본인 비즈니스 로직 — 구독 활성화, 라이선스 plan 반영, 기능 잠금 해제 등
// 참고: data.license_type 이 cp-auth 에 자동 set 되어 있어 verifyCpJwt 로
// user.license_type 즉시 확인 가능 (별도 sync 불필요)
await db.subscriptionOrders.update({ cp_order_id: orderId }, {
status: "succeeded",
payment_key: data.payment_key,
subscription_id: data.subscription_id,
license_type: data.license_type,
});
await activateSubscription(me.id, data.license_type);
return Response.json({ ok: true, license_type: data.license_type });
}{
"status": "success",
"payment_key": "tviva20240101...",
"order_id": "extsub_yourapp_abc123_xyz789",
"amount": 19900,
"currency": "KRW",
"method": "카드",
"approved_at": "2026-05-20T10:30:00+00:00",
"receipt_url": "https://dashboard.tosspayments.com/receipt/...",
"subscription_id": "sub_uuid",
"license_type": "pro"
}구독 관리 (해지·재개)
외부 서비스에서 직접 구현하지 마세요. CP Platform 마이페이지에서 통합 처리됩니다:
https://cp-platform.com/mypage/subscription
사용자에게 이 URL 로 보내는 링크/버튼만 두면 됩니다. 해지·재개 결과는 아래 구독 상태 조회로 확인합니다(현재 구독 이벤트 webhook 은 발송되지 않습니다 — 발송 이벤트 목록은 Webhook 가이드 참고).
구독 상태 조회
본인 서비스에서 사용자의 현재 구독 상태가 필요하면:
GET
https://pay.cp-platform.com/subscriptions/status/external?user_id={uuid}const r = await fetch(
`https://pay.cp-platform.com/subscriptions/status/external?user_id=${cpUserId}`,
{ headers: { "X-API-Key": process.env.CP_PAY_API_KEY! } },
);
const sub = await r.json();
// { status: "active" | "cancelled" | "none", license_type: "pro" | "free" | ..., expires_at }캐싱: 5~10초 정도. 매 요청마다 호출하지 말고 user session 에 cache.
Sandbox
CP-Pay의 TOSS_SECRET_KEY 가 test_ prefix면 자동으로 테스트 모드. Toss 테스트 카드 (5570-2330-1052-7763 등) 로 실결제 없이 통합 검증 가능. 구독도 동일.