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 → response

Step 1 — backend: create the checkout session

POSThttps://pay.cp-platform.com/payments/direct/checkout
Authentication
API Key
Permission
pay.payments.direct
Rate Limit
600 / min
your-app/api/checkout/create.ts
export 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);
}
Success response
{
  "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
}
Error response (HTTP 200, status=error)
{ "status": "error", "message": "Payment service is not configured. Contact your administrator." }

Step 2 — frontend: Toss widget

your-app/checkout-page.tsx
"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.

your-app/payment/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 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:

your-app/payment/fail/page.tsx
"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

POSThttps://pay.cp-platform.com/payments/direct/confirm
Authentication
API Key
Permission
pay.payments.direct
Rate Limit
600 / min
your-app/api/checkout/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();
 
  // ❶ 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