CP-Pay Direct (one-off payments)
Take one-off payments with a two-step /payments/direct/checkout + /payments/direct/confirm flow. You embed the TossPayments SDK widget on your own frontend.
Payment flow
[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 → open TossPayments SDK
loadTossPayments(client_key).requestPayment({ orderId, amount, successUrl, failUrl, orderName })
[4] User → enters card → completes payment
[5] Toss → redirect → success_url?paymentKey=...&orderId=...&amount=...
[6] App BE → POST /payments/direct/confirm (payment_key, order_id, amount)
[7] CP-Pay → payment confirmed → responseStep 1 — backend: create the checkout session
https://pay.cp-platform.com/payments/direct/checkoutexport async function POST(req: Request) {
// ❶ Pull the authenticated user → your DB profiles.cp_user_id mapping
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, // ❷ authenticated user, never client-supplied
product_name: productName,
amount, // integer (KRW: won, USD: cents)
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();
// ❸ Validate the response — both HTTP 200 *and* status="ready"
// cp-pay returns non-fatal errors as 200 + status="error"
if (!res.ok || data.status !== "ready") {
console.error("[cp-pay checkout]", res.status, data);
return Response.json(
{ error: data.message || "Failed to start checkout." },
{ status: res.status === 200 ? 502 : res.status },
);
}
// ❹ Record the order in your DB — needed for amount tampering check & idempotency on confirm
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": "Premium plan",
"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": "Payment service is not configured. Contact your administrator." }Step 2 — frontend: Toss widget
"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 ?? "Could not start checkout.");
}
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 don't execute.
} catch (err: any) {
// Toss SDK rejection — user cancel / card declined / etc.
if (err?.code === "USER_CANCEL" || err?.code === "PAY_PROCESS_CANCELED") return;
console.error("[toss]", err?.code, err?.message);
alert(err?.message ?? "Payment failed.");
}
}Step 3 — frontend: success / fail pages
After payment completes Toss redirects to success_url with paymentKey/orderId/amount in the query. Forward those to your backend to finalize.
"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 parameters.");
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 || "Confirm failed");
return r.json();
})
.then(() => setState("ok"))
.catch((e) => { setState("err"); setMsg(e.message); });
}, [sp]);
if (state === "loading") return <p>Finalizing payment…</p>;
if (state === "ok") return <p>Payment complete.</p>;
return <p>Failed: {msg}</p>;
}fail_url only needs to display the code/message Toss appended:
"use client";
import { useSearchParams } from "next/navigation";
export default function Fail() {
const sp = useSearchParams();
return <p>Payment failed ({sp.get("code")}): {sp.get("message")}</p>;
}Step 4 — backend: confirm the payment
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();
// ❶ Look up the order saved in Step 1 and verify ownership + amount
const order = await db.orders.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) {
// client-supplied amount diverges from the amount you locked in — tampering
return Response.json({ error: "Amount mismatch." }, { status: 400 });
}
if (order.status === "succeeded") {
return Response.json({ ok: true, duplicate: true }); // idempotent
}
// ❷ Hit 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();
// ❸ Validate response
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 ?? "Confirm failed." },
{ status: 400 },
);
}
// ❹ Your business logic — credit points, unlock content, grant license, etc.
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
If the production CP-Pay TOSS_SECRET_KEY starts with test_, the service is automatically in test mode. You can validate integrations with Toss test cards (e.g. 5570-2330-1052-7763) without real charges.
To switch to production, swap in a live Toss key and docker compose up -d cp-pay once. No application-side code changes.
There is no checkout_url endpoint
TossPayments is an SDK widget model — the backend does not issue a redirect URL for the payment page. The response only contains SDK parameters (client_key, order_id, amount, success_url, fail_url).
If you need a redirect-only flow (i.e. take payments using only your backend, without integrating the SDK), a hosted-checkout page needs to be added to cp-platform — this is not the standard pattern and can be evaluated on request.
Next
- Subscriptions — recurring billing and licenses
- Webhooks — receive payment / refund events