按需订阅允许您一次性授权客户的支付方式,然后在需要时收取可变金额,而不是按照固定的时间表进行收费。
此功能可能需要在您的帐户上启用。如果您在仪表板中看不到它,请联系支持。
使用本指南:
- 创建按需订阅(授权授权并可选初始价格)
- 使用自定义金额触发后续收费
- 使用 Webhook 跟踪结果
有关一般订阅设置,请参阅 订阅集成指南。
先决条件
- Dodo Payments 商户帐户和 API 密钥
- 配置的 Webhook 密钥和接收事件的端点
- 您目录中的订阅产品
如果您希望客户通过托管结账批准授权,请设置 payment_link: true 并提供 return_url。
按需工作原理
- 您使用
on_demand 对象创建订阅以授权支付方式,并可选地收取初始费用。
- 之后,您使用专用收费端点针对该订阅创建自定义金额的收费。
- 您监听 Webhook(例如,
payment.succeeded, payment.failed)以更新您的系统。
创建按需订阅
端点:POST /subscriptions
关键请求字段(主体):
如果为 true,则创建一个托管结账链接以进行授权和可选的初始付款。
如果为 true,则在创建时授权支付方式而不向客户收费。
初始收费金额(以最小货币单位计)。如果指定,则此值将覆盖在产品创建期间设置的产品原始价格。如果省略,则使用产品存储的价格。示例:要收费 $1.00,请传递 100。
on_demand.product_currency
初始收费的可选货币覆盖。默认为产品货币。
on_demand.product_description
账单和行项目的可选描述覆盖。
on_demand.adaptive_currency_fees_inclusive
如果为 true,则在 product_price 中包含自适应货币费用。如果为 false,则费用会额外添加。当自适应定价被禁用时,将被忽略。
创建按需订阅
Node.js SDK
Python SDK
Go SDK
cURL
import DodoPayments from 'dodopayments';
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
environment: 'test_mode', // defaults to 'live_mode'
});
async function main() {
const subscription = await client.subscriptions.create({
billing: { city: 'SF', country: 'US', state: 'CA', street: '1 Market St', zipcode: '94105' },
customer: { customer_id: 'customer_123' },
product_id: 'prod_sub_123',
quantity: 1,
payment_link: true,
return_url: 'https://example.com/billing/success',
on_demand: {
mandate_only: true, // set false to collect an initial charge
// product_price: 1000, // optional: charge $10.00 now if mandate_only is false
// product_currency: 'USD',
// product_description: 'Custom initial charge',
// adaptive_currency_fees_inclusive: false,
},
});
// If payment_link was true, redirect the customer to authorize the mandate
console.log(subscription.payment_link);
}
main().catch(console.error);
import os
from dodopayments import DodoPayments
client = DodoPayments(
bearer_token=os.environ.get('DODO_PAYMENTS_API_KEY'),
environment="test_mode", # defaults to "live_mode"
)
subscription = client.subscriptions.create(
billing={
"city": "SF",
"country": "US",
"state": "CA",
"street": "1 Market St",
"zipcode": "94105",
},
customer={"customer_id": "customer_123"},
product_id="prod_sub_123",
quantity=1,
payment_link=True,
return_url="https://example.com/billing/success",
on_demand={
"mandate_only": True,
# "product_price": 1000,
# "product_currency": "USD",
# "product_description": "Custom initial charge",
# "adaptive_currency_fees_inclusive": False,
},
)
print(subscription.payment_link)
package main
import (
"context"
"fmt"
"github.com/dodopayments/dodopayments-go"
"github.com/dodopayments/dodopayments-go/option"
)
func main() {
client := dodopayments.NewClient(
option.WithBearerToken("YOUR_API_KEY"),
)
subscription, err := client.Subscriptions.New(context.TODO(), dodopayments.SubscriptionNewParams{
Billing: dodopayments.F(dodopayments.BillingAddressParam{
City: dodopayments.F("SF"),
Country: dodopayments.F(dodopayments.CountryCodeUs),
State: dodopayments.F("CA"),
Street: dodopayments.F("1 Market St"),
Zipcode: dodopayments.F("94105"),
}),
Customer: dodopayments.F[dodopayments.CustomerRequestUnionParam](dodopayments.AttachExistingCustomerParam{
CustomerID: dodopayments.F("customer_123"),
}),
ProductID: dodopayments.F("prod_sub_123"),
Quantity: dodopayments.F(int64(1)),
PaymentLink: dodopayments.F(true),
ReturnURL: dodopayments.F("https://example.com/billing/success"),
OnDemand: dodopayments.F(dodopayments.OnDemandSubscriptionReqParam{
MandateOnly: dodopayments.F(true),
// ProductPrice: dodopayments.F(int64(1000)),
// ProductCurrency: dodopayments.F(dodopayments.CurrencyUsd),
// ProductDescription: dodopayments.F("Custom initial charge"),
// AdaptiveCurrencyFeesInclusive: dodopayments.F(false),
}),
})
if err != nil { panic(err) }
fmt.Println(subscription.PaymentLink)
}
curl -X POST "$DODO_API/subscriptions" \
-H "Authorization: Bearer $DODO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"billing": {"city": "SF", "country": "US", "state": "CA", "street": "1 Market St", "zipcode": "94105"},
"customer": {"customer_id": "customer_123"},
"product_id": "prod_sub_123",
"quantity": 1,
"payment_link": true,
"return_url": "https://example.com/billing/success",
"on_demand": {
"mandate_only": true
}
}'
设置 payment_link: true,将客户重定向到 payment_link 以完成授权。
{
"subscription_id": "sub_123",
"payment_link": "https://pay.dodopayments.com/checkout/...",
"customer": { "customer_id": "customer_123", "email": "[email protected]", "name": "Alex Doe" },
"metadata": {},
"recurring_pre_tax_amount": 0,
"addons": []
}
收取按需订阅费用
在授权后,根据需要创建收费。
端点:POST /subscriptions/{subscription_id}/charge
关键请求字段(主体):
收费金额(以最小货币单位计)。示例:要收费 $25.00,请传递 2500。
adaptive_currency_fees_inclusive
如果为 true,则在 product_price 中包含自适应货币费用。如果为 false,则费用会额外添加。
Node.js SDK
Python SDK
Go SDK
cURL
import DodoPayments from 'dodopayments';
const client = new DodoPayments({
bearerToken: process.env.DODO_PAYMENTS_API_KEY,
environment: 'test_mode', // defaults to 'live_mode'
});
async function chargeNow(subscriptionId) {
const res = await client.subscriptions.charge(subscriptionId, { product_price: 2500 });
console.log(res.payment_id);
}
chargeNow('sub_123').catch(console.error);
import os
from dodopayments import DodoPayments
client = DodoPayments(
bearer_token=os.environ.get('DODO_PAYMENTS_API_KEY'),
environment="test_mode", # defaults to "live_mode"
)
response = client.subscriptions.charge(
subscription_id="sub_123",
product_price=2500,
)
print(response.payment_id)
package main
import (
"context"
"fmt"
"github.com/dodopayments/dodopayments-go"
"github.com/dodopayments/dodopayments-go/option"
)
func main() {
client := dodopayments.NewClient(option.WithBearerToken("YOUR_API_KEY"))
res, err := client.Subscriptions.Charge(context.TODO(), "sub_123", dodopayments.SubscriptionChargeParams{
ProductPrice: dodopayments.F(int64(2500)),
})
if err != nil { panic(err) }
fmt.Println(res.PaymentID)
}
curl -X POST "$DODO_API/subscriptions/sub_123/charge" \
-H "Authorization: Bearer $DODO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"product_price": 2500,
"product_description": "Extra usage for March"
}'
{ "payment_id": "pay_abc123" }
对非按需订阅进行收费可能会失败。确保在收费之前,订阅的详细信息中包含 on_demand: true。
付款重试
我们的欺诈检测系统可能会阻止激进的重试模式(并可能将其标记为潜在的卡片测试)。遵循安全重试策略。
突发重试模式可能会被我们的风险系统和处理器标记为欺诈或可疑的卡片测试。避免集群重试;遵循下面的退避计划和时间对齐指导。
安全重试策略的原则
- 退避机制:在重试之间使用指数退避。
- 重试限制:限制总重试次数(最多 3-4 次尝试)。
- 智能过滤:仅在可重试的失败(例如,网络/发卡行错误、资金不足)时重试;绝不要重试硬拒绝。
- 防止卡片测试:不要重试诸如
DO_NOT_HONOR, STOLEN_CARD, LOST_CARD, PICKUP_CARD, FRAUDULENT, AUTHENTICATION_FAILURE。
- 可选的元数据变化:如果您维护自己的重试系统,通过元数据区分重试(例如,
retry_attempt)。
建议的重试计划(订阅)
- 第一次尝试:在您创建收费时立即进行
- 第二次尝试:3 天后
- 第三次尝试:再过 7 天(总共 10 天)
- 第四次尝试(最终):再过 7 天(总共 17 天)
最后一步:如果仍未付款,请根据您的政策将订阅标记为未付款或取消它。在客户更新其支付方式的窗口期间通知客户。
避免突发重试;与授权时间对齐
- 将重试锚定到原始授权时间戳,以避免在您的投资组合中出现“突发”行为。
- 示例:如果客户在今天下午 1:10 开始试用或授权,请根据您的退避计划在后续天数的下午 1:10 安排后续重试(例如,+3 天 → 下午 1:10,+7 天 → 下午 1:10)。
- 或者,如果您存储最后一次成功付款时间
T,请在 T + X days 安排下一次尝试,以保持时间对齐。
时区和夏令时:使用一致的时间标准进行调度,仅在显示时进行转换以保持间隔。
不应重试的拒绝代码
STOLEN_CARD
DO_NOT_HONOR
FRAUDULENT
PICKUP_CARD
AUTHENTICATION_FAILURE
LOST_CARD
有关拒绝原因的完整列表及其是否可由用户更正的信息,请参阅
交易失败 文档。
仅在软/临时问题(例如, insufficient_funds, issuer_unavailable, processing_error, 网络超时)时重试。如果同一拒绝重复,请暂停进一步重试。
实施指南(无代码)
- 使用持久化精确时间戳的调度程序/队列;在确切的时间偏移处计算下一次尝试(例如,
T + 3 days 在相同的 HH:MM)。
- 维护并引用最后一次成功付款时间戳
T 以计算下一次尝试;不要在同一时刻聚集多个订阅。
- 始终评估最后一次拒绝原因;对于上述跳过列表中的硬拒绝停止重试。
- 限制每个客户和每个帐户的并发重试,以防止意外激增。
- 主动沟通:在下次计划的尝试之前,通过电子邮件/SMS 通知客户更新其支付方式。
- 仅将元数据用于可观察性(例如,
retry_attempt);绝不要试图通过轮换无关字段来“规避”欺诈/风险系统。
使用 Webhook 跟踪结果
实现 Webhook 处理以跟踪客户旅程。请参阅 实现 Webhook。
- subscription.active:授权的授权和激活的订阅
- subscription.failed:创建失败(例如,授权失败)
- subscription.on_hold:订阅被搁置(例如,未付款状态)
- payment.succeeded:收费成功
- payment.failed:收费失败
对于按需流程,专注于 payment.succeeded 和 payment.failed 以对账基于使用的收费。
测试和后续步骤
在测试模式下创建
使用您的测试 API 密钥创建订阅,使用 payment_link: true,然后打开链接并完成授权。
触发收费
使用小的 product_price(例如, 100)调用收费端点,并验证您收到 payment.succeeded。
上线
在验证事件和内部状态更新后,切换到您的实时 API 密钥。
故障排除
- 422 无效请求:确保在创建时提供
on_demand.mandate_only,并在收费时提供 product_price。
- 货币错误:如果您覆盖
product_currency,请确认它对您的帐户和客户是支持的。
- 未收到 Webhook:验证您的 Webhook URL 和签名密钥配置.