Skip to main content

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:
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.

Environment Variables

Add the following environment variables to your API server:
# 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:
# TossPayments client key (safe for client-side usage)
NEXT_PUBLIC_TOSS_CLIENT_KEY=test_ck_xxxxxxxxxxxxxxxxxxxxxxxx
Never expose TOSS_SECRET_KEY or TOSS_WEBHOOK_SECRET to the client. These must only be used server-side.

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.
1
Create a cart and add items
2
Use the SDK to create a cart and add products:
3
TypeScript
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
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},
)
cURL
# 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}'
4
Initiate checkout with TossPayments
5
Call the checkout endpoint with payment_method: 'tosspayments'. The API returns toss_payment data needed to render the TossPayments widget:
6
TypeScript
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
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
cURL
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"
  }'
7
Complete payment with TossPayments Widget SDK
8
Use the TossPayments Widget SDK on your frontend to display the payment UI and let the customer complete payment:
9
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);
  }
}
10
For a React component:
11
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>
  );
}
12
Confirm payment server-side
13
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:
14
TypeScript
// 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
# 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"
cURL
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
  }'
15
Always confirm payments server-side. Never skip this step — without confirmation, the payment will be automatically cancelled after a timeout period.

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

Event Types

TossPayments EventHeadless 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_DEPOSITVirtual 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 typePrefixExample
Client keytest_ck_test_ck_D5GePWvyJ...
Secret keytest_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
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.

Testing webhooks locally

Use a tunneling tool like ngrok to forward webhook events to your local server:
# 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:
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);
    }
  }
}
Payment confirmation errors:
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);
    }
  }
}

Handling the failure redirect

When a payment fails, TossPayments redirects to your failUrl with error details:
// 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 CodeDescriptionSuggested Action
PAY_PROCESS_CANCELEDUser cancelled the paymentAllow the user to retry
PAY_PROCESS_ABORTEDPayment process was abortedAllow the user to retry
REJECT_CARD_COMPANYCard company rejected the paymentAsk to use a different card
EXCEED_MAX_DAILY_PAYMENT_COUNTDaily payment limit exceededTry again the next day
EXCEED_MAX_PAYMENT_AMOUNTMaximum payment amount exceededReduce the order amount
INVALID_CARD_EXPIRATIONInvalid card expiration dateAsk to re-enter card details
NOT_SUPPORTED_INSTALLMENT_PLANInstallment plan not supportedChoose a different installment option

Next Steps

Webhooks

Learn about all webhook events and payload formats.

Stripe

Set up Stripe for global payment methods.

API Reference

Explore all checkout and payment endpoints.

SDKs

Full SDK reference with every resource and method.