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 → responseDifferences from direct payments:
- Automatic license grant — on successful confirm,
license_typeis 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
https://pay.cp-platform.com/subscriptions/checkout/externalexport 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);
}{
"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"
}{ "status": "error", "message": "Payment service is not configured. Contact administrator." }{ "detail": "An active subscription already exists." }Step 2 — Frontend: Toss widget
Identical to the direct payment pattern.
"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.
"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
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();
// ❶ 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 });
}{
"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:
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 }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
- Direct payments — one-shot charges
- Webhooks — receive subscription activated/cancelled/expired events