업셀과 다운셀을 통해 고객이 저장한 결제 수단을 사용하여 추가 상품이나 요금제 변경을 제안할 수 있습니다. 이는 결제 수집을 생략한 원클릭 구매를 가능하게 하여 전환율을 극적으로 향상시킵니다.
Post-Purchase Upsells
체크아웃 직후 보완 상품을 원클릭으로 제공하세요.
Subscription Upgrades
자동 비례 배분과 즉각적인 청구로 고객을 상위 요금제로 이동시키세요.
Cross-Sells
기존 고객에게 결제 정보를 다시 입력받지 않고 관련 상품을 추가하세요.
개요
업셀과 다운셀은 강력한 수익 최적화 전략입니다:- 업셀: 더 높은 가치의 제품 또는 업그레이드 제안 (예: 기본 대신 프로 플랜)
- 다운셀: 고객이 거절하거나 다운그레이드할 때 낮은 가격의 대안 제안
- 교차 판매: 보완 제품 제안 (예: 추가 기능, 관련 항목)
payment_method_id 매개변수를 통해 이러한 흐름을 가능하게 하며, 고객이 카드 정보를 다시 입력하지 않아도 저장된 결제 수단을 청구할 수 있게 해줍니다.
주요 이점
| 혜택 | 효과 |
|---|---|
| 원클릭 구매 | 재방문 고객은 결제 양식을 건너뜁니다 |
| 전환율 향상 | 구매 결정 순간의 마찰을 줄입니다 |
| 즉시 처리 | 결제가 confirm: true와 함께 즉시 처리됩니다 |
| 인앱 경험 | 전체 과정에서 고객이 앱을 벗어나지 않습니다 |
작동 방식
필수 조건
업셀과 다운셀을 구현하기 전에 다음을 확인하세요:- 저장된 payment methods가 있는 고객(첫 구매 후 자동으로 저장됨)
- 대시보드에서 구성한 upsell 상품(one-time payments, subscriptions 또는 add-ons)
payment.succeeded,payment.failed및subscription.plan_changed이벤트를 처리하도록 구성된 webhook endpoint
고객 payment methods 가져오기
upsell을 제공하기 전에 고객의 저장된 payment methods를 가져옵니다:- TypeScript
- Python
- Go
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.retrievePaymentMethods(customerId);
// Returns { items: [...] } — the list of saved payment methods.
// Each item has: payment_method_id, payment_method, payment_method_type, last_used_at,
// recurring_enabled, and card (last4_digits, card_network, card_type, expiry_month, expiry_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.items[0]?.payment_method_id;
import os
from dodopayments import DodoPayments
client = DodoPayments(
bearer_token=os.environ.get("DODO_PAYMENTS_API_KEY"),
environment="live_mode",
)
def get_payment_methods(customer_id: str):
payment_methods = client.customers.retrieve_payment_methods(customer_id)
# Returns an object with an `items` list of saved payment methods.
# Each item has: payment_method_id, payment_method, payment_method_type, last_used_at,
# recurring_enabled, and card (last4_digits, card_network, card_type, expiry_month, expiry_year)
return payment_methods
# Example usage
methods = get_payment_methods("cus_123")
print("Available payment methods:", methods)
# Use the first available method for upsell
primary_method = methods.items[0].payment_method_id if methods.items else None
package main
import (
"context"
"fmt"
"os"
"github.com/dodopayments/dodopayments-go"
"github.com/dodopayments/dodopayments-go/option"
)
func getPaymentMethods(customerID string) ([]dodopayments.CustomerGetPaymentMethodsResponseItem, error) {
client := dodopayments.NewClient(
option.WithBearerToken(os.Getenv("DODO_PAYMENTS_API_KEY")),
)
resp, err := client.Customers.GetPaymentMethods(
context.TODO(),
customerID,
)
if err != nil {
return nil, err
}
return resp.Items, nil
}
func main() {
methods, err := getPaymentMethods("cus_123")
if err != nil {
panic(err)
}
fmt.Println("Available payment methods:", methods)
// Use the first available method for upsell
if len(methods) > 0 {
primaryMethod := methods[0].PaymentMethodID
fmt.Println("Primary method:", primaryMethod)
}
}
고객이 checkout을 완료하면 payment methods가 자동으로 저장됩니다. 명시적으로 저장할 필요가 없습니다.
구매 후 원클릭 upsell
구매가 성공적으로 완료된 직후 추가 상품을 제공합니다. payment method가 이미 저장되어 있으므로 고객은 한 번의 클릭으로 수락할 수 있습니다.구현
- TypeScript
- Python
- Go
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.retrievePaymentMethods(customerId);
if (methods.items.length === 0) {
console.log('No saved payment methods available');
return null;
}
// Create the upsell with one-click checkout
const upsell = await createOneClickUpsell(
customerId,
methods.items[0].payment_method_id,
'pdt_premium_addon'
);
console.log('Upsell processed:', upsell.session_id);
return upsell;
}
import os
from dodopayments import DodoPayments
client = DodoPayments(
bearer_token=os.environ.get("DODO_PAYMENTS_API_KEY"),
environment="live_mode",
)
def create_one_click_upsell(
customer_id: str,
payment_method_id: str,
upsell_product_id: str
):
"""Create a one-click upsell using saved payment method."""
# Create checkout session with saved payment method
# confirm=True processes the payment immediately
session = client.checkout_sessions.create(
product_cart=[
{
"product_id": upsell_product_id,
"quantity": 1
}
],
customer={
"customer_id": customer_id
},
payment_method_id=payment_method_id,
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
def handle_post_purchase_upsell(customer_id: str):
"""Offer premium add-on after initial purchase."""
# Get customer's payment methods
methods = client.customers.retrieve_payment_methods(customer_id)
if not methods.items:
print("No saved payment methods available")
return None
# Create the upsell with one-click checkout
upsell = create_one_click_upsell(
customer_id=customer_id,
payment_method_id=methods.items[0].payment_method_id,
upsell_product_id="pdt_premium_addon"
)
print(f"Upsell processed: {upsell.session_id}")
return upsell
package main
import (
"context"
"fmt"
"os"
"github.com/dodopayments/dodopayments-go"
"github.com/dodopayments/dodopayments-go/option"
)
func createOneClickUpsell(
customerID string,
paymentMethodID string,
upsellProductID string,
) (*dodopayments.CheckoutSessionResponse, error) {
client := dodopayments.NewClient(
option.WithBearerToken(os.Getenv("DODO_PAYMENTS_API_KEY")),
)
// Create checkout session with saved payment method
// Confirm: true processes the payment immediately
session, err := client.CheckoutSessions.New(context.TODO(), dodopayments.CheckoutSessionNewParams{
CheckoutSessionRequest: dodopayments.CheckoutSessionRequestParam{
ProductCart: dodopayments.F([]dodopayments.ProductItemReqParam{
{
ProductID: dodopayments.F(upsellProductID),
Quantity: dodopayments.F(int64(1)),
},
}),
Customer: dodopayments.F[dodopayments.CustomerRequestUnionParam](
dodopayments.AttachExistingCustomerParam{
CustomerID: dodopayments.F(customerID),
},
),
PaymentMethodID: dodopayments.F(paymentMethodID),
Confirm: dodopayments.F(true), // Required when using payment_method_id
ReturnURL: dodopayments.F("https://yourapp.com/upsell-success"),
FeatureFlags: dodopayments.F(dodopayments.CheckoutSessionFlagsParam{
RedirectImmediately: dodopayments.F(true), // Skip success page
}),
Metadata: dodopayments.F(map[string]string{
"upsell_source": "post_purchase",
"original_order_id": "order_123",
}),
},
})
return session, err
}
func handlePostPurchaseUpsell(customerID string) (*dodopayments.CheckoutSessionResponse, error) {
client := dodopayments.NewClient(
option.WithBearerToken(os.Getenv("DODO_PAYMENTS_API_KEY")),
)
// Get customer's payment methods
resp, err := client.Customers.GetPaymentMethods(context.TODO(), customerID)
if err != nil {
return nil, err
}
if len(resp.Items) == 0 {
fmt.Println("No saved payment methods available")
return nil, nil
}
// Create the upsell with one-click checkout
upsell, err := createOneClickUpsell(
customerID,
resp.Items[0].PaymentMethodID,
"pdt_premium_addon",
)
if err != nil {
return nil, err
}
fmt.Printf("Upsell processed: %s\n", upsell.SessionID)
return upsell, nil
}
checkout session에
payment_method_id를 전달할 때는 confirm: true도 설정하고 기존 customer_id를 제공해야 합니다. payment method는 해당 고객에게 속해야 합니다. POST /payments에는 confirm 필드가 없습니다.Subscription 업그레이드
자동 proration 처리와 함께 고객을 더 높은 등급의 subscription plan으로 이동합니다.확정 전 미리 보기
고객에게 실제 청구 금액을 정확히 보여주려면 항상 plan 변경을 미리 확인합니다:- TypeScript
- Python
- Go
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,
effectiveAt: preview.immediate_charge?.effective_at
};
}
// Show customer the charge before confirming
const preview = await previewUpgrade('sub_123', 'pdt_pro_plan');
console.log(`Upgrade will charge: ${preview.immediateCharge}`);
def preview_upgrade(subscription_id: str, new_product_id: str):
preview = client.subscriptions.preview_change_plan(
subscription_id=subscription_id,
product_id=new_product_id,
quantity=1,
proration_billing_mode="difference_immediately"
)
return {
"immediate_charge": preview.immediate_charge.summary if preview.immediate_charge else None,
"new_plan": preview.new_plan,
"effective_at": preview.immediate_charge.effective_at if preview.immediate_charge else None,
}
# Show customer the charge before confirming
preview = preview_upgrade("sub_123", "pdt_pro_plan")
print(f"Upgrade will charge: {preview['immediate_charge']}")
func previewUpgrade(subscriptionID string, newProductID string) (map[string]interface{}, error) {
client := dodopayments.NewClient(
option.WithBearerToken(os.Getenv("DODO_PAYMENTS_API_KEY")),
)
preview, err := client.Subscriptions.PreviewChangePlan(
context.TODO(),
subscriptionID,
dodopayments.SubscriptionPreviewChangePlanParams{
UpdateSubscriptionPlanReq: dodopayments.UpdateSubscriptionPlanReqParam{
ProductID: dodopayments.F(newProductID),
Quantity: dodopayments.F(int64(1)),
ProrationBillingMode: dodopayments.F(dodopayments.UpdateSubscriptionPlanReqProrationBillingModeDifferenceImmediately),
},
},
)
if err != nil {
return nil, err
}
return map[string]interface{}{
"immediate_charge": preview.ImmediateCharge.Summary,
"new_plan": preview.NewPlan,
"effective_at": preview.ImmediateCharge.EffectiveAt,
}, nil
}
업그레이드 실행
- TypeScript
- Python
- Go
async function upgradeSubscription(
subscriptionId: string,
newProductId: string,
prorationMode: 'prorated_immediately' | 'difference_immediately' | 'full_immediately' | 'do_not_bill' = 'difference_immediately'
) {
// change-plan returns 200 with a ChangePlanResponse body (payment_id, payment_link,
// client_secret, expires_on). All four are null when the change settles off-session.
await client.subscriptions.changePlan(subscriptionId, {
product_id: newProductId,
quantity: 1,
proration_billing_mode: prorationMode
});
// Re-read the subscription to observe the applied state.
return await client.subscriptions.retrieve(subscriptionId);
}
// Upgrade from Basic ($30) to Pro ($80)
// With difference_immediately: charges $50 instantly
const upgrade = await upgradeSubscription('sub_123', 'pdt_pro_plan');
console.log('Upgrade status:', upgrade.status);
def upgrade_subscription(
subscription_id: str,
new_product_id: str,
proration_mode: str = "difference_immediately"
):
# change_plan returns 200 with a ChangePlanResponse body (payment_id, payment_link,
# client_secret, expires_on). All four are None when the change settles off-session.
client.subscriptions.change_plan(
subscription_id=subscription_id,
product_id=new_product_id,
quantity=1,
proration_billing_mode=proration_mode
)
# Re-read the subscription to observe the applied state.
return client.subscriptions.retrieve(subscription_id)
# Upgrade from Basic ($30) to Pro ($80)
# With difference_immediately: charges $50 instantly
upgrade = upgrade_subscription("sub_123", "pdt_pro_plan")
print(f"Upgrade status: {upgrade.status}")
func upgradeSubscription(
subscriptionID string,
newProductID string,
prorationMode dodopayments.UpdateSubscriptionPlanReqProrationBillingMode,
) error {
client := dodopayments.NewClient(
option.WithBearerToken(os.Getenv("DODO_PAYMENTS_API_KEY")),
)
// ChangePlan returns a SubscriptionChangePlanResponse alongside the error.
_, err := client.Subscriptions.ChangePlan(
context.TODO(),
subscriptionID,
dodopayments.SubscriptionChangePlanParams{
UpdateSubscriptionPlanReq: dodopayments.UpdateSubscriptionPlanReqParam{
ProductID: dodopayments.F(newProductID),
Quantity: dodopayments.F(int64(1)),
ProrationBillingMode: dodopayments.F(prorationMode),
},
},
)
return err
}
func main() {
// Upgrade from Basic ($30) to Pro ($80)
// With DifferenceImmediately: charges $50 instantly
err := upgradeSubscription(
"sub_123",
"pdt_pro_plan",
dodopayments.UpdateSubscriptionPlanReqProrationBillingModeDifferenceImmediately,
)
if err != nil {
panic(err)
}
fmt.Println("Upgrade succeeded")
}
Proration 모드
업그레이드 시 고객에게 청구할 방식을 선택합니다:difference_immediately
가격 차액을 즉시 청구합니다($30→$80 = $50). 간단한 업그레이드에 적합합니다.
prorated_immediately
기존 plan의 사용하지 않은 기간을 credit으로 처리한 후 새 plan의 전체 cycle 요금을 청구합니다. 사용하지 않은 기간을 credit으로 제공할 때 적합합니다.
full_immediately
새 plan의 전체 가격을 청구하고 남은 기간은 무시합니다. billing cycle을 재설정할 때 적합합니다.
do_not_bill
즉시 청구 없이 plan을 변경합니다. 다음 renewal 시 새 plan이 청구되며 기존 billing date는 유지됩니다. courtesy 업그레이드와 무료 migration에 적합합니다.
간단한 업그레이드 흐름에는
difference_immediately를 사용하세요. 청구 금액은 단순한 가격 차액입니다. 현재 plan에서 사용하지 않은 기간을 고객에게 credit으로 제공하고 새 plan의 전체 cycle 요금을 청구하려면 prorated_immediately를 사용하세요.Cross-sell
고객이 결제 정보를 다시 입력하지 않아도 기존 고객에게 보완 상품을 추가합니다.구현
- TypeScript
- Python
- Go
async function createCrossSell(
customerId: string,
paymentMethodId: string,
productId: string,
quantity: number = 1
) {
// Create a one-time payment using saved payment method
// Note: POST /payments is deprecated — prefer checkout sessions for new
// integrations. `payment_method_id` is passed the same way.
const payment = await client.payments.create({
product_cart: [
{
product_id: productId,
quantity: quantity
}
],
customer: { customer_id: customerId },
billing: { country: 'US', city: 'San Francisco', state: 'CA', street: '1 Market St', zipcode: '94105' },
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.retrievePaymentMethods(customerId);
if (methods.items.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.items[0].payment_method_id, relatedProductId);
}
def create_cross_sell(
customer_id: str,
payment_method_id: str,
product_id: str,
quantity: int = 1
):
"""Create a one-time payment using saved payment method."""
# Note: POST /payments is deprecated — prefer checkout sessions for new
# integrations. payment_method_id is passed the same way.
payment = client.payments.create(
product_cart=[
{
"product_id": product_id,
"quantity": quantity
}
],
customer={"customer_id": customer_id},
billing={"country": "US", "city": "San Francisco", "state": "CA", "street": "1 Market St", "zipcode": "94105"},
payment_method_id=payment_method_id,
return_url="https://yourapp.com/purchase-complete",
metadata={
"purchase_type": "cross_sell",
"source": "product_recommendation"
}
)
return payment
def offer_related_product(customer_id: str, related_product_id: str):
"""Offer related product with one-click purchase if possible."""
methods = client.customers.retrieve_payment_methods(customer_id)
if not methods.items:
# Fall back to standard checkout
return client.checkout_sessions.create(
product_cart=[{"product_id": related_product_id, "quantity": 1}],
customer={"customer_id": customer_id},
return_url="https://yourapp.com/purchase-complete"
)
# One-click purchase
return create_cross_sell(customer_id, methods.items[0].payment_method_id, related_product_id)
func createCrossSell(
customerID string,
paymentMethodID string,
productID string,
quantity int64,
) (*dodopayments.PaymentNewResponse, error) {
client := dodopayments.NewClient(
option.WithBearerToken(os.Getenv("DODO_PAYMENTS_API_KEY")),
)
// Note: POST /payments is deprecated — prefer checkout sessions for new
// integrations. PaymentMethodID is passed the same way.
payment, err := client.Payments.New(context.TODO(), dodopayments.PaymentNewParams{
ProductCart: dodopayments.F([]dodopayments.OneTimeProductCartItemParam{
{
ProductID: dodopayments.F(productID),
Quantity: dodopayments.F(quantity),
},
}),
Customer: dodopayments.F[dodopayments.CustomerRequestUnionParam](
dodopayments.AttachExistingCustomerParam{CustomerID: dodopayments.F(customerID)},
),
Billing: dodopayments.F(dodopayments.BillingAddressParam{
Country: dodopayments.F(dodopayments.CountryCodeUs),
City: dodopayments.F("San Francisco"),
State: dodopayments.F("CA"),
Street: dodopayments.F("1 Market St"),
Zipcode: dodopayments.F("94105"),
}),
PaymentMethodID: dodopayments.F(paymentMethodID),
ReturnURL: dodopayments.F("https://yourapp.com/purchase-complete"),
Metadata: dodopayments.F(map[string]string{
"purchase_type": "cross_sell",
"source": "product_recommendation",
}),
})
return payment, err
}
func offerRelatedProduct(customerID string, relatedProductID string) (interface{}, error) {
client := dodopayments.NewClient(
option.WithBearerToken(os.Getenv("DODO_PAYMENTS_API_KEY")),
)
resp, err := client.Customers.GetPaymentMethods(context.TODO(), customerID)
if err != nil {
return nil, err
}
if len(resp.Items) == 0 {
// Fall back to standard checkout
return client.CheckoutSessions.New(context.TODO(), dodopayments.CheckoutSessionNewParams{
CheckoutSessionRequest: dodopayments.CheckoutSessionRequestParam{
ProductCart: dodopayments.F([]dodopayments.ProductItemReqParam{
{ProductID: dodopayments.F(relatedProductID), Quantity: dodopayments.F(int64(1))},
}),
Customer: dodopayments.F[dodopayments.CustomerRequestUnionParam](
dodopayments.AttachExistingCustomerParam{CustomerID: dodopayments.F(customerID)},
),
ReturnURL: dodopayments.F("https://yourapp.com/purchase-complete"),
},
})
}
// One-click purchase
return createCrossSell(customerID, resp.Items[0].PaymentMethodID, relatedProductID, 1)
}
Subscription 다운그레이드
고객이 더 낮은 등급의 plan으로 이동하려는 경우 자동 credit을 사용해 전환을 원활하게 처리합니다.다운그레이드 작동 방식
- 고객이 다운그레이드를 요청합니다(Pro → Basic)
- 시스템이 현재 plan의 남은 가치를 계산합니다
- 향후 renewal을 위해 subscription에 credit이 추가됩니다
- 고객이 즉시 새 plan으로 이동합니다
- TypeScript
- Python
- Go
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'
});
// customer_credits is positive when credit is added to the balance, negative when credit is used
console.log('Customer credit change:', preview.immediate_charge.summary.customer_credits);
// Execute the downgrade. change-plan returns 200 with a ChangePlanResponse body.
await client.subscriptions.changePlan(subscriptionId, {
product_id: newProductId,
quantity: 1,
proration_billing_mode: 'difference_immediately'
});
// Credits are automatically applied to future renewals
return await client.subscriptions.retrieve(subscriptionId);
}
// Downgrade from Pro ($80) to Basic ($30)
// $50 credit added to subscription, auto-applied on next renewal
const downgrade = await downgradeSubscription('sub_123', 'pdt_basic_plan');
def downgrade_subscription(subscription_id: str, new_product_id: str):
# Preview the downgrade first
preview = client.subscriptions.preview_change_plan(
subscription_id=subscription_id,
product_id=new_product_id,
quantity=1,
proration_billing_mode="difference_immediately"
)
# customer_credits is positive when credit is added to the balance, negative when credit is used
print(f"Customer credit change: {preview.immediate_charge.summary.customer_credits}")
# Execute the downgrade. change_plan returns 200 with a ChangePlanResponse body.
client.subscriptions.change_plan(
subscription_id=subscription_id,
product_id=new_product_id,
quantity=1,
proration_billing_mode="difference_immediately"
)
# Credits are automatically applied to future renewals
return client.subscriptions.retrieve(subscription_id)
# Downgrade from Pro ($80) to Basic ($30)
# $50 credit added to subscription, auto-applied on next renewal
downgrade = downgrade_subscription("sub_123", "pdt_basic_plan")
func downgradeSubscription(subscriptionID string, newProductID string) error {
client := dodopayments.NewClient(
option.WithBearerToken(os.Getenv("DODO_PAYMENTS_API_KEY")),
)
// Preview the downgrade first
preview, err := client.Subscriptions.PreviewChangePlan(
context.TODO(),
subscriptionID,
dodopayments.SubscriptionPreviewChangePlanParams{
UpdateSubscriptionPlanReq: dodopayments.UpdateSubscriptionPlanReqParam{
ProductID: dodopayments.F(newProductID),
Quantity: dodopayments.F(int64(1)),
ProrationBillingMode: dodopayments.F(dodopayments.UpdateSubscriptionPlanReqProrationBillingModeDifferenceImmediately),
},
},
)
if err != nil {
return err
}
fmt.Printf("Customer credits to be applied: %v\n", preview.ImmediateCharge.Summary.CustomerCredits)
// Execute the downgrade (returns a SubscriptionChangePlanResponse alongside the error)
_, err = client.Subscriptions.ChangePlan(
context.TODO(),
subscriptionID,
dodopayments.SubscriptionChangePlanParams{
UpdateSubscriptionPlanReq: dodopayments.UpdateSubscriptionPlanReqParam{
ProductID: dodopayments.F(newProductID),
Quantity: dodopayments.F(int64(1)),
ProrationBillingMode: dodopayments.F(dodopayments.UpdateSubscriptionPlanReqProrationBillingModeDifferenceImmediately),
},
},
)
return err
}
difference_immediately를 사용한 다운그레이드 credit은 subscription 범위에 적용되며 향후 renewal에 자동으로 적용됩니다. 이는 Credit-Based Billing entitlement와는 별개입니다.전체 예시: 구매 후 upsell 흐름
다음은 구매 성공 후 upsell을 제공하는 전체 구현 예시입니다:- TypeScript
- Python
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.customer_id;
const productId = event.data.product_cart?.[0]?.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.retrievePaymentMethods(customerId);
if (methods.items.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.price.currency
},
paymentMethodId: methods.items[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.retrievePaymentMethods(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.items[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> = {
'pdt_basic_plan': 'pdt_pro_plan',
'pdt_starter_course': 'pdt_complete_bundle',
'pdt_single_license': 'pdt_team_license'
};
return upsellMap[purchasedProductId] || null;
}
app.listen(3000);
import os
from flask import Flask, request, jsonify
from dodopayments import DodoPayments
client = DodoPayments(
bearer_token=os.environ.get("DODO_PAYMENTS_API_KEY"),
environment="live_mode",
)
app = Flask(__name__)
# Store for tracking upsell eligibility (use your database in production)
eligible_upsells = {}
@app.route('/webhooks/dodo', methods=['POST'])
def webhook_handler():
event = request.json
if event['type'] == 'payment.succeeded':
# Check if customer is eligible for upsell
customer_id = event['data']['customer']['customer_id']
product_id = (event['data'].get('product_cart') or [{}])[0].get('product_id')
# Define upsell rules
upsell_product = get_upsell_product(product_id)
if upsell_product:
eligible_upsells[customer_id] = {
'customer_id': customer_id,
'product_id': upsell_product
}
elif event['type'] == 'payment.failed':
print(f"Payment failed: {event['data']['payment_id']}")
return jsonify({'received': True})
@app.route('/api/upsell/<customer_id>', methods=['GET'])
def check_upsell(customer_id):
upsell = eligible_upsells.get(customer_id)
if not upsell:
return jsonify({'eligible': False})
# Get payment methods
methods = client.customers.retrieve_payment_methods(customer_id)
if not methods.items:
return jsonify({'eligible': False, 'reason': 'no_payment_method'})
# Get product details for display
product = client.products.retrieve(upsell['product_id'])
return jsonify({
'eligible': True,
'product': {
'id': product.product_id,
'name': product.name,
'price': product.price,
'currency': product.price.currency
},
'payment_method_id': methods.items[0].payment_method_id
})
@app.route('/api/upsell/<customer_id>/accept', methods=['POST'])
def accept_upsell(customer_id):
upsell = eligible_upsells.get(customer_id)
if not upsell:
return jsonify({'error': 'No upsell available'}), 400
try:
methods = client.customers.retrieve_payment_methods(customer_id)
# Create one-click purchase
session = client.checkout_sessions.create(
product_cart=[{'product_id': upsell['product_id'], 'quantity': 1}],
customer={'customer_id': customer_id},
payment_method_id=methods.items[0].payment_method_id,
confirm=True,
return_url=f"{os.environ['APP_URL']}/upsell-success",
feature_flags={'redirect_immediately': True},
metadata={'upsell': 'true', 'source': 'post_purchase'}
)
# Clear the upsell offer
del eligible_upsells[customer_id]
return jsonify({'success': True, 'session_id': session.session_id})
except Exception as error:
print(f"Upsell failed: {error}")
return jsonify({'error': 'Upsell processing failed'}), 500
def get_upsell_product(purchased_product_id: str) -> str:
"""Determine upsell product based on purchased product."""
upsell_map = {
'pdt_basic_plan': 'pdt_pro_plan',
'pdt_starter_course': 'pdt_complete_bundle',
'pdt_single_license': 'pdt_team_license'
}
return upsell_map.get(purchased_product_id)
if __name__ == '__main__':
app.run(port=3000)
모범 사례
- 전략적으로 시점 선택: 고객이 구매 의사가 있는 성공적인 구매 직후에 upsell을 제안하세요. 그 외 효과적인 시점으로는 기능 사용 milestone 이후, plan 한도에 가까워졌을 때, onboarding 완료 시점이 있습니다.
- payment methods 검증: 원클릭 결제를 시도하기 전에 payment method가 상품의 currency와 호환되는지, 만료되지 않았는지, 고객에게 속하는지 확인하세요.
- 실패를 원활하게 처리: 원클릭 결제가 실패하면 standard checkout 흐름으로 전환하고, 명확한 메시지로 고객에게 알리며, payment method 업데이트를 안내하세요.
- 명확한 가치 제공: 고객이 현재 plan과 비교해 무엇을 얻는지 보여주고, 전체 가격이 아닌 가격 차이를 강조하며, social proof를 활용하세요.
- 고객의 선택 존중: 항상 쉽게 거절할 방법을 제공하고, 거절한 후 동일한 upsell을 반복해서 표시하지 않으며, 어떤 upsell이 전환되는지 추적해 제안을 최적화하세요.
모니터링할 Webhooks
upsell 및 downgrade 흐름에서 다음 webhook 이벤트를 추적하세요:| Event | Trigger | Action |
|---|---|---|
payment.succeeded | Upsell/cross-sell 결제 완료 | 상품 제공, access 업데이트 |
payment.failed | 원클릭 결제 실패 | 오류 표시, 재시도 또는 fallback 제공 |
subscription.plan_changed | 업그레이드/다운그레이드 완료 | 기능 업데이트, 확인 메시지 전송 |
subscription.active | plan 변경 후 Subscription 재활성화 | 새 tier에 대한 access 부여 |
Webhook Integration Guide
webhook endpoint를 설정하고 확인하는 방법을 알아보세요.
관련 리소스
Subscription Upgrade Guide
plan 변경, proration 모드 및 실패 처리에 대한 자세한 가이드입니다.
Checkout Sessions
모든 옵션을 사용해 checkout session을 생성하는 전체 reference입니다.
Customer Payment Methods API
고객 payment methods를 나열하는 API reference입니다.
Add-ons
추가 수익을 위해 유연한 add-ons로 subscriptions를 강화하세요.