CP-Pay 단건 결제 (Direct)

/payments/direct/checkout + /payments/direct/confirm 2단계 흐름으로 단건 결제를 받습니다. TossPayments SDK 위젯을 자기 프론트에 통합하는 패턴입니다.

결제 흐름

[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  → 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 /payments/direct/confirm (payment_key, order_id, amount)
[7] CP-Pay  → 결제 확정 → 응답

Step 1 — 백엔드: checkout 세션 생성

POSThttps://pay.cp-platform.com/payments/direct/checkout
인증
API Key
권한
pay.payments.direct
Rate Limit
600 / min
your-app/api/checkout/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 { 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,                        // ❷ 인증된 user, 클라이언트 입력 금지
      product_name: productName,
      amount,                                        // 정수 (KRW: 원, USD: 센트)
      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();
 
  // ❸ 응답 검증 — HTTP 200 + status="ready" 둘 다 확인
  //    cp-pay 는 toss 미구성 등 비치명적 오류를 200 + status="error" 로 반환
  if (!res.ok || data.status !== "ready") {
    console.error("[cp-pay checkout]", res.status, data);
    return Response.json(
      { error: data.message || "결제 세션 생성에 실패했습니다." },
      { status: res.status === 200 ? 502 : res.status },
    );
  }
 
  // ❹ 본인 DB 에 주문 기록 — confirm 단계에서 amount 변조 검증 + 멱등성에 필요
  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": "프리미엄 플랜",
  "amount": 9900,
  "currency": "KRW",
  "success_url": "https://your-app.com/payment/success",
  "fail_url": "https://your-app.com/payment/fail",
  "is_foreign": false
}
에러 응답 (HTTP 200, status=error)
{ "status": "error", "message": "결제 서비스가 구성되지 않았습니다. 관리자에게 문의해주세요." }

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

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 ?? "결제 시작에 실패했습니다.");
  }
  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) {
    // Toss SDK reject — 사용자 취소 / 카드사 거절
    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/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 중복 방지
  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/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 || "결제 승인 실패");
        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 로 넘기므로 보여주는 정적 페이지로 충분:

your-app/payment/fail/page.tsx
"use client";
import { useSearchParams } from "next/navigation";
export default function Fail() {
  const sp = useSearchParams();
  return <p>결제 실패 ({sp.get("code")}): {sp.get("message")}</p>;
}

Step 4 — 백엔드: 결제 확정

POSThttps://pay.cp-platform.com/payments/direct/confirm
인증
API Key
권한
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();
 
  // ❶ 본인 DB 의 order 검증 — Step 1 에서 저장한 row 와 매칭
  const order = await db.orders.findOne({ cp_order_id: orderId });
  if (!order || order.user_id !== me.id) {
    return Response.json({ error: "주문을 찾을 수 없습니다." }, { status: 404 });
  }
  if (order.amount !== amount) {
    // 클라이언트가 보낸 amount 와 DB 에 박아둔 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/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();
 
  // ❸ 응답 검증
  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 ?? "결제 승인에 실패했습니다." },
      { status: 400 },
    );
  }
 
  // ❹ 본인 비즈니스 로직 — 포인트 충전, 라이선스 발급, 콘텐츠 잠금 해제 등
  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

현재 운영 CP-Pay의 TOSS_SECRET_KEY 가 test_ prefix면 자동으로 테스트 모드입니다. Toss 테스트 카드(5570-2330-1052-7763 등)로 실결제 없이 통합 검증이 가능합니다.

운영 전환 시 Toss 라이브 키로 교체 후 docker compose up -d cp-pay 재기동만 하면 됩니다. 우리 쪽 코드 변경 없음.

checkout_url 발급 endpoint는 없습니다

토스페이먼츠는 SDK 위젯 모델이라 백엔드가 결제창 URL을 발급하지 않습니다. 응답에는 SDK 파라미터(client_key, order_id, amount, success_url, fail_url) 만 들어 있습니다.

자기 백엔드만으로 결제를 받고 싶다면 (즉, SDK 없이 redirect URL 만으로) cp-platform에 별도 hosted checkout 페이지 추가가 필요합니다 — 표준 패턴은 아니므로 요청 시 검토합니다.

다음으로