> ## 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 Integration

> Set up TossPayments for Korean payment methods

# TossPayments Integration

Accept Korean payment methods including credit/debit cards, bank transfers, virtual accounts, and mobile payments using TossPayments.

## Prerequisites

Before you begin, make sure you have:

* A **TossPayments account** — [sign up at tosspayments.com](https://www.tosspayments.com) if you don't have one
* Your **API keys** from the [TossPayments Developer Center](https://developers.tosspayments.com)
* A working Headless Commerce store with at least one product

<Note>
  Use **test mode** keys during development. Test keys use the `test_ck_` and `test_sk_` prefixes. Switch to live keys only when you're ready to accept real payments.
</Note>

## Environment Variables

Add the following environment variables to your API server:

```bash theme={null}
# TossPayments secret key (server-side only — never expose this to the client)
TOSS_SECRET_KEY=test_sk_xxxxxxxxxxxxxxxxxxxxxxxx

# TossPayments webhook signing secret
TOSS_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
```

For your frontend application, add the client key:

```bash theme={null}
# TossPayments client key (safe for client-side usage)
NEXT_PUBLIC_TOSS_CLIENT_KEY=test_ck_xxxxxxxxxxxxxxxxxxxxxxxx
```

<Warning>
  Never expose `TOSS_SECRET_KEY` or `TOSS_WEBHOOK_SECRET` to the client. These must only be used server-side.
</Warning>

## Checkout Flow

The TossPayments checkout flow involves four steps: creating a cart, initiating checkout, completing payment with the TossPayments Widget SDK, and confirming the payment server-side.

<Steps>
  ### Create a cart and add items

  Use the SDK to create a cart and add products:

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

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

    // Create a cart
    const cart = await client.carts.create({
      session_id: 'session_abc123',
    });

    // Add items
    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",
    }

    # Create a cart
    cart = requests.post(f"{base_url}/storefront/carts", headers=headers, json={
        "session_id": "session_abc123",
    }).json()

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

    ```bash cURL theme={null}
    # Create a cart
    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"}'

    # Add items
    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>

  ### Initiate checkout with TossPayments

  Call the checkout endpoint with `payment_method: 'tosspayments'`. The API returns `toss_payment` data needed to render the TossPayments widget:

  <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',
    });

    // The response includes TossPayments data
    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);    // "Classic T-Shirt 외 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()

    # The response includes TossPayments data
    toss = order["toss_payment"]
    print(order["id"])        # order_xxxxxxxx
    print(toss["order_id"])   # HC-order_xxxxxxxx
    print(toss["order_name"]) # "Classic T-Shirt 외 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>

  ### Complete payment with TossPayments Widget SDK

  Use the TossPayments Widget SDK on your frontend to display the payment UI and let the customer complete payment:

  ```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' });

  // Set the payment amount
  await widgets.setAmount({
    currency: 'KRW',
    value: order.toss_payment.amount,
  });

  // Render the payment method selector
  await widgets.renderPaymentMethods({
    selector: '#payment-method',
    variantKey: 'DEFAULT',
  });

  // Render the terms agreement
  await widgets.renderAgreement({
    selector: '#agreement',
    variantKey: 'AGREEMENT',
  });

  // When the customer clicks "Pay", request payment
  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) {
      // User cancelled or an error occurred
      console.error(error);
    }
  }
  ```

  For a React component:

  ```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>
    );
  }
  ```

  ### Confirm payment server-side

  After the customer completes payment, TossPayments redirects to your `successUrl` with `paymentKey`, `orderId`, and `amount` as query parameters. You must confirm the payment by calling the Headless Commerce confirm endpoint:

  <CodeGroup>
    ```typescript TypeScript theme={null}
    // In your success page (e.g., /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')!);

    // Confirm the payment through 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}
    # In your success route handler
    payment_key = request.args.get("paymentKey")
    order_id = request.args.get("orderId")
    amount = int(request.args.get("amount"))

    # Confirm the payment through 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>
    Always confirm payments server-side. Never skip this step — without confirmation, the payment will be automatically cancelled after a timeout period.
  </Warning>
</Steps>

## Webhook Setup

Headless Commerce listens for TossPayments webhook events to handle asynchronous payment updates (e.g., virtual account deposits, cancellations).

Configure webhooks in the TossPayments Developer Center:

1. Go to **Developer Center** → **Webhook Settings**
2. Enter your webhook URL: `https://api.headlesscommerce.io/v1/webhooks/tosspayments`
3. Select the events to receive
4. Save the **Webhook secret** and set it as `TOSS_WEBHOOK_SECRET`

### Webhook Signature Verification

TossPayments signs webhook payloads using HMAC-SHA256. The signature is included in the `x-tosspayments-signature` header.

Headless Commerce verifies this automatically, but if you need to implement custom verification:

<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)
    );
  }

  // Usage in an Express/Hono handler
  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: 'Invalid signature' });
    }

    const event = JSON.parse(rawBody.toString());
    console.log(event.eventType); // e.g., "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": "Invalid signature"}), 401

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

### Event Types

| TossPayments Event                                    | Headless Commerce Action                                    |
| ----------------------------------------------------- | ----------------------------------------------------------- |
| `PAYMENT_STATUS_CHANGED` (status: `DONE`)             | Order status set to `confirmed`, payment marked `completed` |
| `PAYMENT_STATUS_CHANGED` (status: `CANCELED`)         | Order cancelled, `payment.refunded` webhook sent            |
| `PAYMENT_STATUS_CHANGED` (status: `PARTIAL_CANCELED`) | Partial refund recorded                                     |
| `VIRTUAL_ACCOUNT_DEPOSIT`                             | Virtual account payment confirmed, order marked `confirmed` |

## Testing

### Test mode keys

Use test mode API keys during development. Test transactions are simulated and do not process real payments.

| Key type   | Prefix     | Example                |
| ---------- | ---------- | ---------------------- |
| Client key | `test_ck_` | `test_ck_D5GePWvyJ...` |
| Secret key | `test_sk_` | `test_sk_zXLkKEypN...` |

### Test payments

In test mode, the TossPayments widget displays simulated payment flows:

* **Credit card**: Select any card brand and complete the simulated authentication
* **Bank transfer**: The transfer is instantly simulated as successful
* **Virtual account**: A test virtual account is created and you can simulate a deposit
* **Mobile payment**: Simulated mobile payment flow

<Note>
  TossPayments test mode does not use specific test card numbers like Stripe. Instead, the entire payment widget operates in a sandbox environment where all payment methods are simulated.
</Note>

### Testing webhooks locally

Use a tunneling tool like ngrok to forward webhook events to your local server:

```bash theme={null}
# Install ngrok
brew install ngrok

# Start a tunnel to your local server
ngrok http 3000

# Use the ngrok URL as your webhook endpoint in TossPayments Developer Center
# e.g., https://abc123.ngrok.io/v1/webhooks/tosspayments
```

## Error Handling

Handle payment errors in both the checkout and confirmation steps:

**Checkout errors:**

<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':
          // Cart has no items
          break;
        case 'insufficient_stock':
          // One or more items are out of stock
          break;
        case 'payment_provider_error':
          // TossPayments returned an error
          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  # Cart has no items
      elif code == "insufficient_stock":
          pass  # One or more items are out of stock
      elif code == "payment_provider_error":
          pass  # TossPayments returned an error
      else:
          print(error.get("message"))
  ```
</CodeGroup>

**Payment confirmation errors:**

<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':
          // The amount does not match the original order
          break;
        case 'payment_already_confirmed':
          // This payment was already confirmed
          break;
        case 'payment_expired':
          // The payment session has expired
          break;
        case 'payment_confirm_failed':
          // TossPayments rejected the confirmation
          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  # Amount does not match the original order
      elif code == "payment_already_confirmed":
          pass  # Payment was already confirmed
      elif code == "payment_expired":
          pass  # Payment session expired
      elif code == "payment_confirm_failed":
          pass  # TossPayments rejected the confirmation
      else:
          print(error.get("message"))
  ```
</CodeGroup>

### Handling the failure redirect

When a payment fails, TossPayments redirects to your `failUrl` with error details:

```typescript theme={null}
// In your failure page (e.g., /payment/fail)
const searchParams = new URLSearchParams(window.location.search);
const errorCode = searchParams.get('code');
const errorMessage = searchParams.get('message');
const orderId = searchParams.get('orderId');

// Display an appropriate message to the user
switch (errorCode) {
  case 'PAY_PROCESS_CANCELED':
    // User cancelled the payment
    console.log('Payment was cancelled by the user.');
    break;
  case 'PAY_PROCESS_ABORTED':
    // Payment was aborted
    console.log('Payment process was aborted.');
    break;
  case 'REJECT_CARD_COMPANY':
    // Card company rejected the payment
    console.log('The card company rejected this payment.');
    break;
  default:
    console.log(`Payment failed: ${errorMessage}`);
}
```

Common TossPayments error codes:

| Error Code                       | Description                       | Suggested Action                      |
| -------------------------------- | --------------------------------- | ------------------------------------- |
| `PAY_PROCESS_CANCELED`           | User cancelled the payment        | Allow the user to retry               |
| `PAY_PROCESS_ABORTED`            | Payment process was aborted       | Allow the user to retry               |
| `REJECT_CARD_COMPANY`            | Card company rejected the payment | Ask to use a different card           |
| `EXCEED_MAX_DAILY_PAYMENT_COUNT` | Daily payment limit exceeded      | Try again the next day                |
| `EXCEED_MAX_PAYMENT_AMOUNT`      | Maximum payment amount exceeded   | Reduce the order amount               |
| `INVALID_CARD_EXPIRATION`        | Invalid card expiration date      | Ask to re-enter card details          |
| `NOT_SUPPORTED_INSTALLMENT_PLAN` | Installment plan not supported    | Choose a different installment option |

## Next Steps

<CardGroup cols={2}>
  <Card title="Webhooks" icon="bell" href="/webhooks">
    Learn about all webhook events and payload formats.
  </Card>

  <Card title="Stripe" icon="credit-card" href="/guides/stripe-integration">
    Set up Stripe for global payment methods.
  </Card>

  <Card title="API Reference" icon="book-open" href="/api-reference/introduction">
    Explore all checkout and payment endpoints.
  </Card>

  <Card title="SDKs" icon="code" href="/sdks">
    Full SDK reference with every resource and method.
  </Card>
</CardGroup>
