메인 콘텐츠로 건너뛰기
업셀과 다운셀을 통해 고객이 저장한 결제 수단을 사용하여 추가 상품이나 요금제 변경을 제안할 수 있습니다. 이는 결제 수집을 생략한 원클릭 구매를 가능하게 하여 전환율을 극적으로 향상시킵니다.

Post-Purchase Upsells

체크아웃 직후 보완 상품을 원클릭으로 제공하세요.

Subscription Upgrades

자동 비례 배분과 즉각적인 청구로 고객을 상위 요금제로 이동시키세요.

Cross-Sells

기존 고객에게 결제 정보를 다시 입력받지 않고 관련 상품을 추가하세요.

개요

업셀과 다운셀은 강력한 수익 최적화 전략입니다:
  • 업셀: 더 높은 가치의 제품 또는 업그레이드 제안 (예: 기본 대신 프로 플랜)
  • 다운셀: 고객이 거절하거나 다운그레이드할 때 낮은 가격의 대안 제안
  • 교차 판매: 보완 제품 제안 (예: 추가 기능, 관련 항목)
Dodo Payments는 payment_method_id 매개변수를 통해 이러한 흐름을 가능하게 하며, 고객이 카드 정보를 다시 입력하지 않아도 저장된 결제 수단을 청구할 수 있게 해줍니다.

주요 이점

BenefitImpact
One-click purchases기존 고객을 위해 결제 양식을 완전히 생략
Higher conversion의사결정 순간의 마찰을 줄임
Instant processingconfirm: true으로 청구가 즉시 처리됨
Seamless UX흐름 전체에서 고객이 앱을 떠나지 않음

작동 방식

필수 조건

업셀과 다운셀을 구현하기 전에 다음을 확인하세요:
1

Customer with Saved Payment Method

고객은 최소한 한 번의 구매를 완료해야 합니다. 결제 수단은 고객이 체크아웃을 완료하면 자동으로 저장됩니다.
2

Products Configured

Dodo Payments 대시보드에서 업셀 상품을 만드세요. 일회성 결제, 구독 또는 애드온이 될 수 있습니다.
3

Webhook Endpoint

payment.succeeded, payment.failed, subscription.plan_changed 이벤트를 처리하도록 웹훅을 설정하세요.

고객 결제 방법 가져오기

업셀을 제안하기 전에 고객의 저장된 결제 방법을 가져옵니다:
import DodoPayments from 'dodopayments';

const client = new DodoPayments({
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
  environment: 'live_mode',
});

async function getPaymentMethods(customerId: string) {
  const paymentMethods = await client.customers.listPaymentMethods(customerId);
  
  // Returns array of saved payment methods
  // Each has: payment_method_id, type, card (last4, brand, exp_month, exp_year)
  return paymentMethods;
}

// Example usage
const methods = await getPaymentMethods('cus_123');
console.log('Available payment methods:', methods);

// Use the first available method for upsell
const primaryMethod = methods[0]?.payment_method_id;
결제 수단은 고객이 체크아웃을 완료하면 자동으로 저장됩니다. 별도로 저장할 필요가 없습니다.

구매 후 원클릭 업셀

성공적인 구매 직후 추가 제품을 제안합니다. 고객은 결제 방법이 이미 저장되어 있기 때문에 한 번의 클릭으로 수락할 수 있습니다.

구현

import DodoPayments from 'dodopayments';

const client = new DodoPayments({
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
  environment: 'live_mode',
});

async function createOneClickUpsell(
  customerId: string,
  paymentMethodId: string,
  upsellProductId: string
) {
  // Create checkout session with saved payment method
  // confirm: true processes the payment immediately
  const session = await client.checkoutSessions.create({
    product_cart: [
      {
        product_id: upsellProductId,
        quantity: 1
      }
    ],
    customer: {
      customer_id: customerId
    },
    payment_method_id: paymentMethodId,
    confirm: true,  // Required when using payment_method_id
    return_url: 'https://yourapp.com/upsell-success',
    feature_flags: {
      redirect_immediately: true  // Skip success page
    },
    metadata: {
      upsell_source: 'post_purchase',
      original_order_id: 'order_123'
    }
  });

  return session;
}

// Example: Offer premium add-on after initial purchase
async function handlePostPurchaseUpsell(customerId: string) {
  // Get customer's payment methods
  const methods = await client.customers.listPaymentMethods(customerId);
  
  if (methods.length === 0) {
    console.log('No saved payment methods available');
    return null;
  }

  // Create the upsell with one-click checkout
  const upsell = await createOneClickUpsell(
    customerId,
    methods[0].payment_method_id,
    'prod_premium_addon'
  );

  console.log('Upsell processed:', upsell.session_id);
  return upsell;
}
payment_method_id을 사용할 때는 confirm: true를 설정하고 기존 customer_id를 제공해야 합니다. 결제 수단은 해당 고객의 것이어야 합니다.

구독 업그레이드

자동 비율 조정을 통해 고객을 상위 등급 구독 플랜으로 이동시킵니다.

커밋 전에 미리보기

항상 고객이 정확히 얼마를 청구받는지 보여주기 위해 플랜 변경을 미리 봅니다:
async function previewUpgrade(
  subscriptionId: string,
  newProductId: string
) {
  const preview = await client.subscriptions.previewChangePlan(subscriptionId, {
    product_id: newProductId,
    quantity: 1,
    proration_billing_mode: 'difference_immediately'
  });

  return {
    immediateCharge: preview.immediate_charge?.summary,
    newPlan: preview.new_plan,
    effectiveDate: preview.effective_date
  };
}

// Show customer the charge before confirming
const preview = await previewUpgrade('sub_123', 'prod_pro_plan');
console.log(`Upgrade will charge: ${preview.immediateCharge}`);

업그레이드 실행

async function upgradeSubscription(
  subscriptionId: string,
  newProductId: string,
  prorationMode: 'prorated_immediately' | 'difference_immediately' | 'full_immediately' = 'difference_immediately'
) {
  const result = await client.subscriptions.changePlan(subscriptionId, {
    product_id: newProductId,
    quantity: 1,
    proration_billing_mode: prorationMode
  });

  return {
    status: result.status,
    subscriptionId: result.subscription_id,
    paymentId: result.payment_id,
    invoiceId: result.invoice_id
  };
}

// Upgrade from Basic ($30) to Pro ($80)
// With difference_immediately: charges $50 instantly
const upgrade = await upgradeSubscription('sub_123', 'prod_pro_plan');
console.log('Upgrade status:', upgrade.status);

비율 조정 모드

업그레이드 시 고객 청구 방법 선택:
ModeBehaviorBest For
difference_immediately가격 차액을 즉시 청구 (3030→80 = $50)단순 업그레이드
prorated_immediately청구 주기의 남은 시간에 따라 청구시간 기반 공정 청구
full_immediately새 요금제의 전체 가격을 청구하며 남은 시간을 무시청구 주기 초기화
간단한 업그레이드 흐름에는 difference_immediately를 사용하세요. 현재 요금제에서 남은 시간을 고려하려면 prorated_immediately를 사용하세요.

교차 판매

저장된 결제 세부 정보를 다시 입력하지 않고 기존 고객에게 보완 제품을 추가합니다.

구현

async function createCrossSell(
  customerId: string,
  paymentMethodId: string,
  productId: string,
  quantity: number = 1
) {
  // Create a one-time payment using saved payment method
  const payment = await client.payments.create({
    product_cart: [
      {
        product_id: productId,
        quantity: quantity
      }
    ],
    customer_id: customerId,
    payment_method_id: paymentMethodId,
    return_url: 'https://yourapp.com/purchase-complete',
    metadata: {
      purchase_type: 'cross_sell',
      source: 'product_recommendation'
    }
  });

  return payment;
}

// Example: Customer bought a course, offer related ebook
async function offerRelatedProduct(customerId: string, relatedProductId: string) {
  const methods = await client.customers.listPaymentMethods(customerId);
  
  if (methods.length === 0) {
    // Fall back to standard checkout
    return client.checkoutSessions.create({
      product_cart: [{ product_id: relatedProductId, quantity: 1 }],
      customer: { customer_id: customerId },
      return_url: 'https://yourapp.com/purchase-complete'
    });
  }

  // One-click purchase
  return createCrossSell(customerId, methods[0].payment_method_id, relatedProductId);
}

구독 다운그레이드

고객이 하위 등급 플랜으로 이동하고자 할 때 자동 크레딧으로 부드럽게 전환을 처리합니다.

다운그레이드 작동 방식

  1. 고객이 다운그레이드 요청 (프로 → 기본)
  2. 시스템이 현재 플랜의 남은 가치를 계산
  3. 향후 갱신을 위해 구독에 크레딧 추가
  4. 고객이 즉시 새로운 플랜으로 이동
async function downgradeSubscription(
  subscriptionId: string,
  newProductId: string
) {
  // Preview the downgrade first
  const preview = await client.subscriptions.previewChangePlan(subscriptionId, {
    product_id: newProductId,
    quantity: 1,
    proration_billing_mode: 'difference_immediately'
  });

  console.log('Credit to be applied:', preview.credit_amount);

  // Execute the downgrade
  const result = await client.subscriptions.changePlan(subscriptionId, {
    product_id: newProductId,
    quantity: 1,
    proration_billing_mode: 'difference_immediately'
  });

  // Credits are automatically applied to future renewals
  return result;
}

// Downgrade from Pro ($80) to Basic ($30)
// $50 credit added to subscription, auto-applied on next renewal
const downgrade = await downgradeSubscription('sub_123', 'prod_basic_plan');
difference_immediately을(를) 사용한 다운그레이드로 발생한 크레딧은 구독 범위이며 향후 갱신에 자동으로 적용됩니다. 이는 Credit-Based Billing 권한과는 별개입니다.

전체 예제: 구매 후 업셀 흐름

성공적인 구매 후 업셀을 제안하는 완전한 구현입니다:
import DodoPayments from 'dodopayments';
import express from 'express';

const client = new DodoPayments({
  bearerToken: process.env.DODO_PAYMENTS_API_KEY,
  environment: 'live_mode',
});

const app = express();

// Store for tracking upsell eligibility (use your database in production)
const eligibleUpsells = new Map<string, { customerId: string; productId: string }>();

// Webhook handler for initial purchase success
app.post('/webhooks/dodo', express.raw({ type: 'application/json' }), async (req, res) => {
  const event = JSON.parse(req.body.toString());
  
  switch (event.type) {
    case 'payment.succeeded':
      // Check if customer is eligible for upsell
      const customerId = event.data.customer_id;
      const productId = event.data.product_id;
      
      // Define upsell rules (e.g., bought Basic, offer Pro)
      const upsellProduct = getUpsellProduct(productId);
      
      if (upsellProduct) {
        eligibleUpsells.set(customerId, {
          customerId,
          productId: upsellProduct
        });
      }
      break;
      
    case 'payment.failed':
      console.log('Payment failed:', event.data.payment_id);
      // Handle failed upsell payment
      break;
  }
  
  res.json({ received: true });
});

// API endpoint to check upsell eligibility
app.get('/api/upsell/:customerId', async (req, res) => {
  const { customerId } = req.params;
  const upsell = eligibleUpsells.get(customerId);
  
  if (!upsell) {
    return res.json({ eligible: false });
  }
  
  // Get payment methods
  const methods = await client.customers.listPaymentMethods(customerId);
  
  if (methods.length === 0) {
    return res.json({ eligible: false, reason: 'no_payment_method' });
  }
  
  // Get product details for display
  const product = await client.products.retrieve(upsell.productId);
  
  res.json({
    eligible: true,
    product: {
      id: product.product_id,
      name: product.name,
      price: product.price,
      currency: product.currency
    },
    paymentMethodId: methods[0].payment_method_id
  });
});

// API endpoint to accept upsell
app.post('/api/upsell/:customerId/accept', async (req, res) => {
  const { customerId } = req.params;
  const upsell = eligibleUpsells.get(customerId);
  
  if (!upsell) {
    return res.status(400).json({ error: 'No upsell available' });
  }
  
  try {
    const methods = await client.customers.listPaymentMethods(customerId);
    
    // Create one-click purchase
    const session = await client.checkoutSessions.create({
      product_cart: [{ product_id: upsell.productId, quantity: 1 }],
      customer: { customer_id: customerId },
      payment_method_id: methods[0].payment_method_id,
      confirm: true,
      return_url: `${process.env.APP_URL}/upsell-success`,
      feature_flags: { redirect_immediately: true },
      metadata: { upsell: 'true', source: 'post_purchase' }
    });
    
    // Clear the upsell offer
    eligibleUpsells.delete(customerId);
    
    res.json({ success: true, sessionId: session.session_id });
  } catch (error) {
    console.error('Upsell failed:', error);
    res.status(500).json({ error: 'Upsell processing failed' });
  }
});

// Helper function to determine upsell product
function getUpsellProduct(purchasedProductId: string): string | null {
  const upsellMap: Record<string, string> = {
    'prod_basic_plan': 'prod_pro_plan',
    'prod_starter_course': 'prod_complete_bundle',
    'prod_single_license': 'prod_team_license'
  };
  
  return upsellMap[purchasedProductId] || null;
}

app.listen(3000);

모범 사례

성공적인 구매 직후, 고객이 구매 마인드에 있을 때 업셀을 제안하는 것이 가장 좋습니다. 다른 효과적인 시점:
  • 기능 사용 이정표 달성 후
  • 요금제 한도 접근 시
  • 온보딩 완료 시
원클릭 청구를 시도하기 전에 결제 수단을 확인하세요:
  • 해당 상품의 통화와 호환되는가
  • 만료되지 않았는가
  • 고객의 것인가
API가 이를 검증하지만, 사전에 확인하면 UX가 향상됩니다.
원클릭 청구에 실패하면:
  1. 표준 체크아웃 흐름으로 되돌아가기
  2. 명확한 메시지로 고객에게 알리기
  3. 결제 수단 업데이트 제안하기
  4. 실패한 청구를 반복 시도하지 않기
업셀은 고객이 가치를 이해할 때 더 잘 전환됩니다:
  • 현재 요금제 대비 무엇을 얻는지 보여주기
  • 총 가격이 아닌 가격 차를 강조하기
  • 사회적 증거 활용 (예: “가장 인기 있는 업그레이드”)
  • 항상 거절할 수 있는 쉬운 방법 제공
  • 거절한 후 동일한 업셀을 반복해서 보여주지 않기
  • 어떤 업셀이 전환되는지 추적하고 분석하여 제안 최적화하기

모니터링을 위한 웹훅

업셀 및 다운그레이드 흐름을 위해 이러한 웹훅 이벤트를 추적합니다:
EventTriggerAction
payment.succeeded업셀/교차 판매 결제 완료상품 제공, 액세스 업데이트
payment.failed원클릭 청구 실패오류 표시, 재시도 또는 대체 제안
subscription.plan_changed업그레이드/다운그레이드 완료기능 업데이트, 확인 메시지 발송
INLINE_CODE_PLACEHOLDER_c4613b9706b26e9b_End요금제 변경 후 구독 재활성화새 티어 액세스 부여

Webhook Integration Guide

웹훅 엔드포인트를 설정하고 확인하는 방법을 알아보세요.

관련 자료