> ## Documentation Index
> Fetch the complete documentation index at: https://docs.headlesscommerce.io/llms.txt
> Use this file to discover all available pages before exploring further.

# TossPayments 연동

> Headless Commerce에서 TossPayments 결제 설정하기

# TossPayments 연동

TossPayments를 사용하여 신용/체크카드, 계좌이체, 가상계좌, 휴대폰 결제 등 한국의 주요 결제 수단을 지원할 수 있습니다.

## 사전 요구 사항

시작하기 전에 다음을 준비해주세요:

* **TossPayments 계정** — 계정이 없다면 [tosspayments.com](https://www.tosspayments.com)에서 가입하세요
* [TossPayments 개발자센터](https://developers.tosspayments.com)에서 **API 키** 확인
* 최소 하나 이상의 상품이 등록된 Headless Commerce 스토어

<Note>
  개발 중에는 **테스트 모드** 키를 사용하세요. 테스트 키는 `test_ck_`와 `test_sk_` 접두사를 사용합니다. 실제 결제를 받을 준비가 되었을 때만 라이브 키로 전환하세요.
</Note>

## 환경 변수

API 서버에 다음 환경 변수를 추가하세요:

```bash theme={null}
# TossPayments 시크릿 키 (서버 전용 — 클라이언트에 절대 노출하지 마세요)
TOSS_SECRET_KEY=test_sk_xxxxxxxxxxxxxxxxxxxxxxxx

# TossPayments 웹훅 서명 시크릿
TOSS_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
```

프론트엔드 애플리케이션에는 클라이언트 키를 추가하세요:

```bash theme={null}
# TossPayments 클라이언트 키 (클라이언트에서 안전하게 사용 가능)
NEXT_PUBLIC_TOSS_CLIENT_KEY=test_ck_xxxxxxxxxxxxxxxxxxxxxxxx
```

<Warning>
  `TOSS_SECRET_KEY`와 `TOSS_WEBHOOK_SECRET`은 절대 클라이언트에 노출하지 마세요. 서버 측에서만 사용해야 합니다.
</Warning>

## 결제 흐름

TossPayments 결제 흐름은 네 단계로 구성됩니다: 장바구니 생성, 체크아웃 시작, TossPayments Widget SDK로 결제 완료, 서버 측에서 결제 승인.

<Steps>
  ### 장바구니 생성 및 상품 추가

  SDK를 사용하여 장바구니를 생성하고 상품을 추가합니다:

  <CodeGroup>
    ```typescript TypeScript theme={null}
    import { createStorefrontClient } from '@headless-commerce/sdk';

    const client = createStorefrontClient({
      apiKey: process.env.NEXT_PUBLIC_HC_API_KEY!,
    });

    // 장바구니 생성
    const cart = await client.carts.create({
      session_id: 'session_abc123',
    });

    // 상품 추가
    await client.carts.addItem(cart.id, {
      variant_id: 'var_xxx',
      quantity: 2,
    });
    ```

    ```python Python theme={null}
    import requests

    base_url = "https://api.headlesscommerce.io/v1"
    headers = {
        "Authorization": "Bearer pk_test_your_key",
        "Content-Type": "application/json",
    }

    # 장바구니 생성
    cart = requests.post(f"{base_url}/storefront/carts", headers=headers, json={
        "session_id": "session_abc123",
    }).json()

    # 상품 추가
    requests.post(
        f"{base_url}/storefront/carts/{cart['id']}/items",
        headers=headers,
        json={"variant_id": "var_xxx", "quantity": 2},
    )
    ```

    ```bash cURL theme={null}
    # 장바구니 생성
    curl -X POST https://api.headlesscommerce.io/v1/storefront/carts \
      -H "Authorization: Bearer pk_test_your_key" \
      -H "Content-Type: application/json" \
      -d '{"session_id": "session_abc123"}'

    # 상품 추가
    curl -X POST https://api.headlesscommerce.io/v1/storefront/carts/{cart_id}/items \
      -H "Authorization: Bearer pk_test_your_key" \
      -H "Content-Type: application/json" \
      -d '{"variant_id": "var_xxx", "quantity": 2}'
    ```
  </CodeGroup>

  ### TossPayments로 체크아웃 시작

  `payment_method: 'tosspayments'`를 지정하여 체크아웃 엔드포인트를 호출합니다. API가 TossPayments 위젯 렌더링에 필요한 `toss_payment` 데이터를 반환합니다:

  <CodeGroup>
    ```typescript TypeScript theme={null}
    const order = await client.carts.checkout(cart.id, {
      email: 'customer@example.com',
      shipping_address: {
        line1: '서울특별시 강남구 테헤란로 123',
        city: '서울',
        postal_code: '06234',
        country: 'KR',
      },
      payment_method: 'tosspayments',
    });

    // 응답에 TossPayments 데이터가 포함됩니다
    const { order_id, order_name, amount } = order.toss_payment;
    console.log(order.id);      // order_xxxxxxxx
    console.log(order_id);      // HC-order_xxxxxxxx
    console.log(order_name);    // "클래식 티셔츠 외 1건"
    console.log(amount);        // 58000
    ```

    ```python Python theme={null}
    order = requests.post(
        f"{base_url}/storefront/carts/{cart['id']}/checkout",
        headers=headers,
        json={
            "email": "customer@example.com",
            "shipping_address": {
                "line1": "서울특별시 강남구 테헤란로 123",
                "city": "서울",
                "postal_code": "06234",
                "country": "KR",
            },
            "payment_method": "tosspayments",
        },
    ).json()

    # 응답에 TossPayments 데이터가 포함됩니다
    toss = order["toss_payment"]
    print(order["id"])        # order_xxxxxxxx
    print(toss["order_id"])   # HC-order_xxxxxxxx
    print(toss["order_name"]) # "클래식 티셔츠 외 1건"
    print(toss["amount"])     # 58000
    ```

    ```bash cURL theme={null}
    curl -X POST https://api.headlesscommerce.io/v1/storefront/carts/{cart_id}/checkout \
      -H "Authorization: Bearer pk_test_your_key" \
      -H "Content-Type: application/json" \
      -d '{
        "email": "customer@example.com",
        "shipping_address": {
          "line1": "서울특별시 강남구 테헤란로 123",
          "city": "서울",
          "postal_code": "06234",
          "country": "KR"
        },
        "payment_method": "tosspayments"
      }'
    ```
  </CodeGroup>

  ### TossPayments Widget SDK로 결제 완료

  프론트엔드에서 TossPayments Widget SDK를 사용하여 결제 UI를 표시하고 고객이 결제를 완료하도록 합니다:

  ```typescript theme={null}
  import { loadTossPayments } from '@tosspayments/tosspayments-sdk';

  const tossPayments = await loadTossPayments(
    process.env.NEXT_PUBLIC_TOSS_CLIENT_KEY!
  );

  const widgets = tossPayments.widgets({ customerKey: 'guest' });

  // 결제 금액 설정
  await widgets.setAmount({
    currency: 'KRW',
    value: order.toss_payment.amount,
  });

  // 결제 수단 선택 위젯 렌더링
  await widgets.renderPaymentMethods({
    selector: '#payment-method',
    variantKey: 'DEFAULT',
  });

  // 이용약관 동의 위젯 렌더링
  await widgets.renderAgreement({
    selector: '#agreement',
    variantKey: 'AGREEMENT',
  });

  // 고객이 "결제하기" 버튼 클릭 시 결제 요청
  async function handlePayment() {
    try {
      await widgets.requestPayment({
        orderId: order.toss_payment.order_id,
        orderName: order.toss_payment.order_name,
        successUrl: `${window.location.origin}/payment/success`,
        failUrl: `${window.location.origin}/payment/fail`,
      });
    } catch (error) {
      // 사용자가 취소했거나 오류 발생
      console.error(error);
    }
  }
  ```

  React 컴포넌트 예시:

  ```typescript theme={null}
  import { useEffect, useRef } from 'react';
  import { loadTossPayments } from '@tosspayments/tosspayments-sdk';

  interface PaymentWidgetProps {
    orderId: string;
    orderName: string;
    amount: number;
  }

  function TossPaymentWidget({ orderId, orderName, amount }: PaymentWidgetProps) {
    const widgetsRef = useRef<ReturnType<
      Awaited<ReturnType<typeof loadTossPayments>>['widgets']
    > | null>(null);

    useEffect(() => {
      async function initWidgets() {
        const tossPayments = await loadTossPayments(
          process.env.NEXT_PUBLIC_TOSS_CLIENT_KEY!
        );
        const widgets = tossPayments.widgets({ customerKey: 'guest' });

        await widgets.setAmount({ currency: 'KRW', value: amount });
        await widgets.renderPaymentMethods({
          selector: '#payment-method',
          variantKey: 'DEFAULT',
        });
        await widgets.renderAgreement({
          selector: '#agreement',
          variantKey: 'AGREEMENT',
        });

        widgetsRef.current = widgets;
      }

      initWidgets();
    }, [amount]);

    const handlePayment = async () => {
      if (!widgetsRef.current) return;

      await widgetsRef.current.requestPayment({
        orderId,
        orderName,
        successUrl: `${window.location.origin}/payment/success`,
        failUrl: `${window.location.origin}/payment/fail`,
      });
    };

    return (
      <div>
        <div id="payment-method" />
        <div id="agreement" />
        <button onClick={handlePayment}>결제하기</button>
      </div>
    );
  }
  ```

  ### 서버 측에서 결제 승인

  고객이 결제를 완료하면 TossPayments가 `successUrl`로 리다이렉트하며 `paymentKey`, `orderId`, `amount`를 쿼리 파라미터로 전달합니다. Headless Commerce의 승인 엔드포인트를 호출하여 결제를 최종 승인해야 합니다:

  <CodeGroup>
    ```typescript TypeScript theme={null}
    // 결제 성공 페이지에서 (예: /payment/success)
    const searchParams = new URLSearchParams(window.location.search);
    const paymentKey = searchParams.get('paymentKey')!;
    const orderId = searchParams.get('orderId')!;
    const amount = Number(searchParams.get('amount')!);

    // Headless Commerce를 통해 결제 승인
    const confirmation = await client.payments.confirm({
      payment_key: paymentKey,
      order_id: orderId,
      amount: amount,
    });

    console.log(confirmation.status); // "confirmed"
    ```

    ```python Python theme={null}
    # 결제 성공 라우트 핸들러에서
    payment_key = request.args.get("paymentKey")
    order_id = request.args.get("orderId")
    amount = int(request.args.get("amount"))

    # Headless Commerce를 통해 결제 승인
    confirmation = requests.post(
        f"{base_url}/storefront/payments/confirm",
        headers=headers,
        json={
            "payment_key": payment_key,
            "order_id": order_id,
            "amount": amount,
        },
    ).json()

    print(confirmation["status"])  # "confirmed"
    ```

    ```bash cURL theme={null}
    curl -X POST https://api.headlesscommerce.io/v1/storefront/payments/confirm \
      -H "Authorization: Bearer pk_test_your_key" \
      -H "Content-Type: application/json" \
      -d '{
        "payment_key": "paymentKey_from_tosspayments",
        "order_id": "HC-order_xxxxxxxx",
        "amount": 58000
      }'
    ```
  </CodeGroup>

  <Warning>
    결제 승인은 반드시 서버 측에서 처리해야 합니다. 이 단계를 건너뛰면 결제가 일정 시간 후 자동으로 취소됩니다.
  </Warning>
</Steps>

## 웹훅 설정

Headless Commerce는 TossPayments 웹훅 이벤트를 수신하여 비동기 결제 업데이트(예: 가상계좌 입금, 취소)를 처리합니다.

TossPayments 개발자센터에서 웹훅을 설정하세요:

1. **개발자센터** → **웹훅 설정**으로 이동
2. 웹훅 URL 입력: `https://api.headlesscommerce.io/v1/webhooks/tosspayments`
3. 수신할 이벤트 선택
4. **웹훅 시크릿**을 저장하고 `TOSS_WEBHOOK_SECRET`으로 설정

### 웹훅 서명 검증

TossPayments는 HMAC-SHA256을 사용하여 웹훅 페이로드에 서명합니다. 서명은 `x-tosspayments-signature` 헤더에 포함됩니다.

Headless Commerce가 자동으로 검증하지만, 커스텀 검증이 필요한 경우:

<CodeGroup>
  ```typescript TypeScript theme={null}
  import crypto from 'node:crypto';

  function verifyTossWebhook(
    payload: string,
    signature: string,
    secret: string
  ): boolean {
    const expected = crypto
      .createHmac('sha256', secret)
      .update(payload)
      .digest('base64');

    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  }

  // Express/Hono 핸들러에서의 사용 예시
  app.post('/webhooks/tosspayments', async (req, res) => {
    const signature = req.headers['x-tosspayments-signature'] as string;
    const rawBody = await getRawBody(req);

    if (!verifyTossWebhook(rawBody.toString(), signature, process.env.TOSS_WEBHOOK_SECRET!)) {
      return res.status(401).json({ error: '유효하지 않은 서명' });
    }

    const event = JSON.parse(rawBody.toString());
    console.log(event.eventType); // 예: "PAYMENT_STATUS_CHANGED"
    res.status(200).json({ success: true });
  });
  ```

  ```python Python theme={null}
  import hmac
  import hashlib
  import base64
  from flask import Flask, request, jsonify

  app = Flask(__name__)

  def verify_toss_webhook(payload: bytes, signature: str, secret: str) -> bool:
      expected = base64.b64encode(
          hmac.new(secret.encode(), payload, hashlib.sha256).digest()
      ).decode()
      return hmac.compare_digest(signature, expected)

  @app.post("/webhooks/tosspayments")
  def handle_webhook():
      signature = request.headers.get("x-tosspayments-signature", "")
      raw_body = request.get_data()

      if not verify_toss_webhook(raw_body, signature, TOSS_WEBHOOK_SECRET):
          return jsonify({"error": "유효하지 않은 서명"}), 401

      event = request.get_json()
      print(event["eventType"])  # 예: "PAYMENT_STATUS_CHANGED"
      return jsonify({"success": True})
  ```
</CodeGroup>

### 이벤트 유형

| TossPayments 이벤트 | Headless Commerce 동작 |
| - | - |
| `PAYMENT_STATUS_CHANGED` (status: `DONE`) | 주문 상태 `confirmed`로 변경, 결제 `completed` 처리 |
| `PAYMENT_STATUS_CHANGED` (status: `CANCELED`) | 주문 취소, `payment.refunded` 웹훅 전송 |
| `PAYMENT_STATUS_CHANGED` (status: `PARTIAL_CANCELED`) | 부분 환불 기록 |
| `VIRTUAL_ACCOUNT_DEPOSIT` | 가상계좌 입금 확인, 주문 `confirmed` 처리 |

## 테스트

### 테스트 모드 키

개발 중에는 테스트 모드 API 키를 사용하세요. 테스트 거래는 시뮬레이션되며 실제 결제가 처리되지 않습니다.

| 키 유형 | 접두사 | 예시 |
| - | - | - |
| 클라이언트 키 | `test_ck_` | `test_ck_D5GePWvyJ...` |
| 시크릿 키 | `test_sk_` | `test_sk_zXLkKEypN...` |

### 테스트 결제

테스트 모드에서 TossPayments 위젯은 시뮬레이션된 결제 흐름을 표시합니다:

* **신용카드**: 아무 카드사를 선택하고 시뮬레이션된 인증을 완료합니다
* **계좌이체**: 즉시 성공으로 시뮬레이션됩니다
* **가상계좌**: 테스트 가상계좌가 생성되며 입금을 시뮬레이션할 수 있습니다
* **휴대폰 결제**: 시뮬레이션된 모바일 결제 흐름

<Note>
  TossPayments 테스트 모드는 Stripe처럼 특정 테스트 카드 번호를 사용하지 않습니다. 대신, 결제 위젯 전체가 샌드박스 환경에서 동작하며 모든 결제 수단이 시뮬레이션됩니다.
</Note>

### 로컬에서 웹훅 테스트

ngrok과 같은 터널링 도구를 사용하여 로컬 서버로 웹훅 이벤트를 전달할 수 있습니다:

```bash theme={null}
# ngrok 설치
brew install ngrok

# 로컬 서버로 터널 시작
ngrok http 3000

# ngrok URL을 TossPayments 개발자센터에서 웹훅 엔드포인트로 설정
# 예: https://abc123.ngrok.io/v1/webhooks/tosspayments
```

## 오류 처리

체크아웃과 결제 승인 두 단계 모두에서 오류를 처리하세요:

**체크아웃 오류:**

<CodeGroup>
  ```typescript TypeScript theme={null}
  try {
    const order = await client.carts.checkout(cart.id, {
      email: 'customer@example.com',
      shipping_address: { /* ... */ },
      payment_method: 'tosspayments',
    });
  } catch (error: unknown) {
    if (error instanceof Error && 'code' in error) {
      const apiError = error as { code: string; message: string };
      switch (apiError.code) {
        case 'cart_empty':
          // 장바구니가 비어 있음
          break;
        case 'insufficient_stock':
          // 하나 이상의 상품 재고가 부족함
          break;
        case 'payment_provider_error':
          // TossPayments에서 오류 반환
          break;
        default:
          console.error(apiError.message);
      }
    }
  }
  ```

  ```python Python theme={null}
  try:
      order = requests.post(
          f"{base_url}/storefront/carts/{cart_id}/checkout",
          headers=headers,
          json={
              "email": "customer@example.com",
              "shipping_address": { ... },
              "payment_method": "tosspayments",
          },
      )
      order.raise_for_status()
  except requests.exceptions.HTTPError as e:
      error = e.response.json()
      code = error.get("code")
      if code == "cart_empty":
          pass  # 장바구니가 비어 있음
      elif code == "insufficient_stock":
          pass  # 하나 이상의 상품 재고가 부족함
      elif code == "payment_provider_error":
          pass  # TossPayments에서 오류 반환
      else:
          print(error.get("message"))
  ```
</CodeGroup>

**결제 승인 오류:**

<CodeGroup>
  ```typescript TypeScript theme={null}
  try {
    const confirmation = await client.payments.confirm({
      payment_key: paymentKey,
      order_id: orderId,
      amount: amount,
    });
  } catch (error: unknown) {
    if (error instanceof Error && 'code' in error) {
      const apiError = error as { code: string; message: string };
      switch (apiError.code) {
        case 'payment_amount_mismatch':
          // 금액이 원래 주문과 일치하지 않음
          break;
        case 'payment_already_confirmed':
          // 이미 승인된 결제
          break;
        case 'payment_expired':
          // 결제 세션이 만료됨
          break;
        case 'payment_confirm_failed':
          // TossPayments가 승인을 거부함
          break;
        default:
          console.error(apiError.message);
      }
    }
  }
  ```

  ```python Python theme={null}
  try:
      confirmation = requests.post(
          f"{base_url}/storefront/payments/confirm",
          headers=headers,
          json={
              "payment_key": payment_key,
              "order_id": order_id,
              "amount": amount,
          },
      )
      confirmation.raise_for_status()
  except requests.exceptions.HTTPError as e:
      error = e.response.json()
      code = error.get("code")
      if code == "payment_amount_mismatch":
          pass  # 금액이 원래 주문과 일치하지 않음
      elif code == "payment_already_confirmed":
          pass  # 이미 승인된 결제
      elif code == "payment_expired":
          pass  # 결제 세션이 만료됨
      elif code == "payment_confirm_failed":
          pass  # TossPayments가 승인을 거부함
      else:
          print(error.get("message"))
  ```
</CodeGroup>

### 실패 리다이렉트 처리

결제가 실패하면 TossPayments가 `failUrl`로 리다이렉트하며 오류 정보를 전달합니다:

```typescript theme={null}
// 결제 실패 페이지에서 (예: /payment/fail)
const searchParams = new URLSearchParams(window.location.search);
const errorCode = searchParams.get('code');
const errorMessage = searchParams.get('message');
const orderId = searchParams.get('orderId');

// 사용자에게 적절한 메시지 표시
switch (errorCode) {
  case 'PAY_PROCESS_CANCELED':
    // 사용자가 결제를 취소함
    console.log('결제가 취소되었습니다.');
    break;
  case 'PAY_PROCESS_ABORTED':
    // 결제가 중단됨
    console.log('결제 프로세스가 중단되었습니다.');
    break;
  case 'REJECT_CARD_COMPANY':
    // 카드사에서 결제를 거절함
    console.log('카드사에서 결제를 거절했습니다.');
    break;
  default:
    console.log(`결제 실패: ${errorMessage}`);
}
```

주요 TossPayments 오류 코드:

| 오류 코드 | 설명 | 권장 조치 |
| - | - | - |
| `PAY_PROCESS_CANCELED` | 사용자가 결제를 취소함 | 재시도 허용 |
| `PAY_PROCESS_ABORTED` | 결제 프로세스가 중단됨 | 재시도 허용 |
| `REJECT_CARD_COMPANY` | 카드사에서 결제 거절 | 다른 카드 사용 요청 |
| `EXCEED_MAX_DAILY_PAYMENT_COUNT` | 일일 결제 횟수 초과 | 다음 날 재시도 |
| `EXCEED_MAX_PAYMENT_AMOUNT` | 최대 결제 금액 초과 | 주문 금액 조정 |
| `INVALID_CARD_EXPIRATION` | 유효하지 않은 카드 만료일 | 카드 정보 재입력 요청 |
| `NOT_SUPPORTED_INSTALLMENT_PLAN` | 지원하지 않는 할부 옵션 | 다른 할부 옵션 선택 |

## 다음 단계

<CardGroup cols={2}>
  <Card title="웹훅" icon="bell" href="/ko/webhooks">
    모든 웹훅 이벤트와 페이로드 형식을 확인하세요.
  </Card>

  <Card title="Stripe" icon="credit-card" href="/ko/guides/stripe-integration">
    글로벌 결제 수단을 위한 Stripe를 설정하세요.
  </Card>

  <Card title="API 레퍼런스" icon="book-open" href="/ko/api-reference/introduction">
    모든 체크아웃 및 결제 엔드포인트를 살펴보세요.
  </Card>

  <Card title="SDK" icon="code" href="/ko/sdks">
    전체 SDK 레퍼런스와 모든 리소스 및 메서드를 확인하세요.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.