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 세션 생성

POSThttps://pay.cp-platform.com/subscriptions/checkout/external
인증
API Key
권한
pay.subscriptions.checkout
Rate Limit
1800 / min
your-app/api/subscription/create.ts
export 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"
}
에러 응답 (HTTP 200, status=error)
{ "status": "error", "message": "결제 서비스가 구성되지 않았습니다. 관리자에게 문의해주세요." }
중복 구독 (HTTP 400)
{ "detail": "이미 활성화된 구독이 있습니다." }

Step 2 — 프론트엔드: Toss 위젯

단건 결제와 100% 동일한 호출 패턴입니다.

your-app/subscription-page.tsx
"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 가 포함됩니다.

your-app/subscription/success/page.tsx
"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 — 백엔드: 결제 확정

POSThttps://pay.cp-platform.com/subscriptions/confirm/external
인증
API Key
권한
pay.subscriptions.checkout
Rate Limit
900 / min
your-app/api/subscription/confirm.ts
export 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 가이드 참고).

구독 상태 조회

본인 서비스에서 사용자의 현재 구독 상태가 필요하면:

GEThttps://pay.cp-platform.com/subscriptions/status/external?user_id={uuid}
인증
API Key
권한
pay.subscriptions.checkout
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 등) 로 실결제 없이 통합 검증 가능. 구독도 동일.

다음으로