CP-Pay Subscriptions

Take CP subscription payments from your external service via the 2-step /subscriptions/checkout/external + /subscriptions/confirm/external flow. Subscription management (cancel/resume) is handled by the CP Platform mypage — you do not need to implement it.

Payment flow

[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  → Open Toss widget via TossPayments SDK
              loadTossPayments(client_key).requestPayment({ orderId, amount, successUrl, failUrl, orderName })
[4] User    → Enter card → payment completes
[5] Toss    → redirect → success_url?paymentKey=...&orderId=...&amount=...
[6] App BE  → POST /subscriptions/confirm/external (payment_key, order_id, amount)
[7] CP-Pay  → Activate subscription + grant license → response

Differences from direct payments:

  • Automatic license grant — on successful confirm, license_type is auto-set in CP-Auth and CP-Notify quota is upgraded to Pro
  • Subscription management belongs to CP — to let users cancel/resume, send them to cp-platform.com/mypage/subscription
  • order_id format — extsub_{service_id}_{user_id}_{random} so confirm can identify the service

Step 1 — Backend: create checkout session

POSThttps://pay.cp-platform.com/subscriptions/checkout/external
Authentication
API Key
Permission
pay.subscriptions.checkout
Rate Limit
1800 / min
your-app/api/subscription/create.ts
export async function POST(req: Request) {
  // ❶ Authenticated user from your backend session → mapped 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,                        // ❷ Authenticated user, never client input
      plan_code: planCode,                            // lowercase / digits / _ only
      success_url: "https://your-app.com/subscription/success",
      fail_url: "https://your-app.com/subscription/fail",
    }),
  });
 
  const data = await res.json();
 
  // ❸ Validate response — both HTTP 200 and status="ready" must be true
  if (!res.ok || data.status !== "ready") {
    console.error("[cp-pay sub checkout]", res.status, data);
    return Response.json(
      { error: data.message || "Failed to create subscription session." },
      { status: res.status === 200 ? 502 : res.status },
    );
  }
 
  // ❹ Persist order in your own DB — needed for amount tamper check + idempotency at confirm
  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);
}
Success response
{
  "status": "ready",
  "client_key": "test_ck_xxx",
  "order_id": "extsub_yourapp_abc123_xyz789",
  "order_name": "CP Pro Subscription",
  "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"
}
Error response (HTTP 200, status=error)
{ "status": "error", "message": "Payment service is not configured. Contact administrator." }
Duplicate subscription (HTTP 400)
{ "detail": "An active subscription already exists." }

Step 2 — Frontend: Toss widget

Identical to the direct payment pattern.

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 ?? "Failed to start subscription.");
  }
  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,
    });
    // On success, Toss redirects to success_url; lines below do not run.
  } catch (err: any) {
    if (err?.code === "USER_CANCEL" || err?.code === "PAY_PROCESS_CANCELED") return;
    console.error("[toss]", err?.code, err?.message);
    alert(err?.message ?? "Error during payment.");
  }
}

Step 3 — Frontend: success / fail pages

After payment, Toss redirects to success_url with paymentKey/orderId/amount query params.

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 dedupe
  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("Missing payment data.");
      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 || "Subscription confirm failed");
        return r.json();
      })
      .then(() => setState("ok"))
      .catch((e) => { setState("err"); setMsg(e.message); });
  }, [sp]);
 
  if (state === "loading") return <p>Activating subscription…</p>;
  if (state === "ok") return <p>Subscription activated.</p>;
  return <p>Failed: {msg}</p>;
}

The fail_url page only needs to display code/message/orderId from the query string.

Step 4 — Backend: confirm payment

POSThttps://pay.cp-platform.com/subscriptions/confirm/external
Authentication
API Key
Permission
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();
 
  // ❶ Validate the order in your own DB
  const order = await db.subscriptionOrders.findOne({ cp_order_id: orderId });
  if (!order || order.user_id !== me.id) {
    return Response.json({ error: "Order not found." }, { status: 404 });
  }
  if (order.amount !== amount) {
    // Amount tamper attempt
    return Response.json({ error: "Amount mismatch." }, { status: 400 });
  }
  if (order.status === "succeeded") {
    return Response.json({ ok: true, duplicate: true });          // idempotent
  }
 
  // ❷ Call 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 ?? "Subscription confirm failed." },
      { status: 400 },
    );
  }
 
  // ❸ Your business logic — activate subscription, set license plan, unlock features
  //    Note: data.license_type is auto-set on cp-auth, so verifyCpJwt returns it
  //    immediately (no extra sync needed).
  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 });
}
Success response
{
  "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"
}

Subscription management (cancel / resume)

Do not implement this in your external service. It is handled by the CP Platform mypage:

https://cp-platform.com/mypage/subscription

Just link/redirect users there. Check the result of cancel/resume with the subscription status lookup below (subscription-event webhooks are not sent today — see the Webhook guide for the events that are).

Subscription status

If you need the current subscription state of a user in your service:

GEThttps://pay.cp-platform.com/subscriptions/status/external?user_id={uuid}
Authentication
API Key
Permission
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 }

Cache it for 5–10s in the user session — don't hit this on every request.

Sandbox

If CP-Pay's TOSS_SECRET_KEY starts with test_, you are automatically in test mode. Use Toss test cards (5570-2330-1052-7763, etc.) to validate end-to-end without real charges. Same for subscriptions.

Next