追加销售和降级销售允许您使用客户已保存的支付方式提供附加产品或计划更改。这使得可以实现跳过支付收集的一键购买,从而显著提高转化率。
Post-Purchase Upsells
使用一键购买在结账后立即提供互补产品。
Subscription Upgrades
利用自动比例分配和即时结算将客户迁移到更高层级。
Cross-Sells
向现有客户添加相关产品,无需重新输入支付信息。
概述
追加销售和降级销售是强大的营收优化策略:- 追加销售:提供更高价值的产品或升级(例如将基本计划升级到专业计划)
- 降级销售:当客户拒绝或降级时提供低价替代选项
- 交叉销售:建议互补产品(例如附加组件、相关商品)
payment_method_id 参数启用这些流程,该参数使您可以在无需客户重新输入卡片详细信息的情况下收取其已保存的支付方式。
主要收益
| 优势 | 影响 |
|---|---|
| 一键购买 | 回访客户无需填写支付表单 |
| 更高的转化率 | 减少决策时刻的阻力 |
| 即时处理 | 使用 confirm: true 立即处理扣款 |
| 应用内体验 | 客户在整个流程中始终留在你的应用内 |
工作原理
前提条件
在实施追加销售和降级销售之前,请确保您已完成以下准备工作:- 已保存支付方式的客户(首次购买后会自动保存)
- 在控制面板中配置的追加销售产品(一次性付款、订阅或附加项)
- 已配置 webhook 端点,以处理
payment.succeeded、payment.failed和subscription.plan_changed事件
获取客户支付方式
提供追加销售之前,先获取客户已保存的支付方式:- 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)
}
}
客户完成结账后,支付方式会自动保存。你无需显式保存它们。
购买后的一键追加销售
在成功购买后立即提供其他产品。由于客户的支付方式已经保存,他们只需点击一次即可接受。实现
- 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
}
将
payment_method_id 传递给 checkout session 时,还必须设置 confirm: true,并提供一个现有的 customer_id。支付方式必须属于该客户。POST /payments 没有 confirm 字段。订阅升级
通过自动按比例计算费用,将客户转移到更高等级的订阅计划。提交前预览
始终预览计划变更,向客户准确展示他们将被收取的费用:- 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")
}
按比例计算模式
选择升级时向客户收费的方式:difference_immediately
立即收取价格差额($30→$80 = $50)。适用于简单的升级。
prorated_immediately
抵扣旧计划的未使用时间,然后收取新计划一个完整周期的费用。适用于抵扣未使用时间。
full_immediately
收取新计划的全额费用,不考虑剩余时间。适用于重置计费周期。
do_not_bill
应用计划变更,但不立即收费;新计划将在下次续订时计费,并保留原始计费日期。适用于礼遇升级和免费迁移。
对于直接的升级流程,请使用
difference_immediately ——收费金额就是简单的价格差额。如果希望客户获得当前计划未使用时间的抵扣,并为新计划支付一个完整周期的费用,请使用 prorated_immediately。交叉销售
为现有客户添加互补产品,无需他们重新输入支付详情。实现
- 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)
}
订阅降级
当客户希望转移到较低等级的计划时,通过自动添加抵扣金额来平稳处理过渡。降级的工作方式
- 客户请求降级(Pro → Basic)
- 系统计算当前计划的剩余价值
- 将抵扣金额添加到订阅中,用于未来续订
- 客户立即转移到新计划
预览中的
immediate_charge.summary.customer_credits 使用客户 credit wallet 的货币,该货币由 customer_credits_currency 指定。它可能与摘要中的 currency 不同,例如客户使用 INR 支付 USD 订阅时。- 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 通过降级获得的 credits 仅限于订阅,并会自动应用于未来的续订。它们不同于 Credit-Based Billing 权益。完整示例:购买后追加销售流程
下面是一个完整的实现示例,展示如何在购买成功后提供追加销售:- 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)
最佳实践
- 选择合适的时机:在购买成功后、客户处于购买意愿较强的状态时立即提供追加销售。其他有效时机包括:客户达到功能使用里程碑、接近计划限制,以及完成 onboarding 时。
- 验证支付方式:在尝试一键扣款前,确认支付方式与产品的货币兼容、尚未过期,并且属于该客户。
- 妥善处理失败情况:一键扣款失败时,回退到标准结账流程,使用清晰的消息通知客户,并提供更新支付方式的选项。
- 清晰展示价值:展示客户将获得的内容与当前计划的差异,突出价格差额(而非总价),并使用社会认同证明。
- 尊重客户选择:始终提供便捷的拒绝方式;客户拒绝后不要反复展示相同的追加销售,并跟踪哪些追加销售能够转化,以优化后续方案。
要监控的 Webhooks
跟踪以下 webhook 事件,以处理追加销售和降级流程:| Event | Trigger | Action |
|---|---|---|
payment.succeeded | 追加销售/交叉销售支付完成 | 交付产品,更新访问权限 |
payment.failed | 一键扣款失败 | 显示错误,提供重试或回退选项 |
subscription.plan_changed | 升级/降级完成 | 更新功能,发送确认信息 |
subscription.active | 计划变更后重新激活订阅 | 授予新层级的访问权限 |
Webhook Integration Guide
了解如何设置和验证 webhook 端点。
相关资源
Subscription Upgrade Guide
详细介绍计划变更、按比例计费模式以及失败处理。
Checkout Sessions
创建具有全部选项的 checkout session 的完整参考。
Customer Payment Methods API
列出客户支付方式的 API 参考。
Add-ons
使用灵活的附加组件扩展订阅,以获取更多收入。