USDC 订阅:无需保存钱包授权,如何收取周期性稳定币付款
用周期性账单、付款人主动结账和已验证的订阅结算事件构建 USDC 订阅。
USDC 订阅不应被设计成试图用钱包私钥或长期钱包授权重现银行卡自动扣款。更可靠的做法是周期性出账:你的服务定义套餐和账期;每张发票产生新的付款请求;客户在 Hosted Checkout 或自有钱包流程中主动发起转账;后端只在已验证的订阅事件表明发票已经结算、订阅已经激活或续期后变更服务权益。
这个模型适合 SaaS、API 积分和数字服务,也能清晰划分托管边界:商户控制收款地址,StableOps 不保管付款人私钥、钱包凭据或可复用扣款授权,付款人决定何时签署每一笔链上转账。
周期性发票不是自动钱包扣款
“加密订阅计费”可能指两种完全不同的系统。把它们混为一谈,容易让钱包流程作出无法兑现的承诺。
| 模型 | 谁发起转账 | 商户保存什么 | 何时变更服务状态 |
|---|---|---|---|
| 自动扣款 | 已预授权的支付机制 | 可复用授权或支付凭据 | 扣款结果可靠后 |
| 周期性稳定币发票 | 每张发票均由付款人发起 | 订阅、发票、支付订单和事件记录 | 已验证的订阅事件记录激活或续期后 |
StableOps 使用第二种模型。套餐定义金额和周期,订阅将套餐关联到 merchantUserId,开放发票可以发起一次付款尝试。付款人可以在托管页面中支付发票,也可以使用你构建的钱包界面。首期付款和浏览器回跳 URL 都不是以后从该钱包扣取 USDC 的授权。
这个边界应体现在产品文案、催缴邮件和账户页面中:说明下一张发票何时开出、客户如何支付;除非另行运营明确授权的支付机制,否则不要承诺钱包会被自动扣款。
将订阅建模为从出账到结算的生命周期
以按月收取 USDC 的套餐为例,可靠路径如下:
套餐:USD 金额 + 账单周期
|
v
为 merchantUserId 创建订阅
|
v
首期或续期账单的开放发票
|
v
Portal session -> Hosted Checkout(或你的钱包流程)
|
v
付款人选择可接受的 USDC 链并按精确付款指令转账
|
v
payment.detected -> payment.confirmed -> payment.finalized
|
v
发票结算 -> end_user_invoice.paid
|
v
end_user_subscription.activated 或 end_user_subscription.renewed
|
v
商户幂等开通或续期服务套餐和发票以 USD 计价,并不预先绑定某一种稳定币。创建结账时传入你能够接收的链和资产组合,例如 Base USDC。StableOps 会为发票创建 Payment Order,并为这些可接受组合分配收款地址。只有当 Payment Order 达到 FINALIZED、发票结算事务将其标记为 paid 时,实际结算 asset 才会回填;在此之前,包括付款请求已经创建后,asset 仍可能为 null。
如果只准备接收 USDC,建议先支持一条已支持的 USDC 链。这样可减少网络选择、地址容量和精确付款指令带来的客服歧义。只有当每个新增组合都有足够的收款地址和对账流程时,再增加更多可接受组合。订阅指南说明了套餐、发票、Portal 和结算模型。
创建首张发票并将付款人跳转到结账页
商户侧操作应始终在后端完成:使用 secret API key 创建套餐、订阅和 Portal session。Portal token 有效期较短,且只限定到一个商户用户;后端可以用它创建发票 checkout session,再只把返回的 Checkout URL 交给浏览器。
import { StableOps } from '@stableops/api-sdk'
const stableops = new StableOps({
apiKey: process.env.STABLEOPS_API_KEY!,
})
const planId = process.env.STABLEOPS_PLAN_ID
if (!planId) throw new Error('STABLEOPS_PLAN_ID is required')
const created = await stableops.merchantSubscriptions.subscriptions.create(
{
planId,
merchantUserId: 'user_123',
},
{ idempotencyKey: 'subscription:user_123:starter' },
)
// 试用订阅要到试用结束后才会开出首张发票。
const invoice = created.invoice
if (!invoice) throw new Error('No invoice is due yet')
const portalSession = await stableops.merchantSubscriptions.portalSessions.create({
merchantUserId: 'user_123',
})
const portal = stableops.portal(portalSession.portalToken)
const checkout = await portal.invoices.checkoutSession(invoice.id, {
acceptedAssets: [{ chain: 'base-sepolia', asset: 'USDC' }],
successUrl: 'https://merchant.example/billing/return',
cancelUrl: 'https://merchant.example/billing/canceled',
})
return Response.redirect(checkout.checkoutUrl, 303)STABLEOPS_PLAN_ID 必须是创建套餐时返回的真实 ID,而不是便于阅读的套餐 code。创建订阅时使用稳定的、商户自有的幂等键,并在自己的账户记录旁保存 StableOps 的订阅和发票 ID。试用订阅在首张发票到期前会返回 invoice: null,因此生产代码必须处理这个分支,不能强制断言非空。包含套餐创建和自有钱包替代方案的完整公开 SDK 流程可见订阅指南。
successUrl 只是浏览器回跳路径,不是支付收据。客户可能关闭标签页、在链上最终性达成前回跳,或在未支付时反复访问该 URL。应根据发票状态和已签名 Webhook 更新计费界面,而不是在回跳时延长权益。
用最终结算而非结账完成控制访问权限
发票 checkout session 包装的是发票自己的 Payment Order,因此订阅发票与一次性付款采用同样的链上状态机。
| 信号 | 适合的用途 | 不能据此做什么 |
|---|---|---|
| 打开 Checkout 或浏览器回跳 | 展示付款进度 | 标记发票已支付或续期服务 |
payment.detected | 展示“已收到付款” | 不可逆的续期或最终账务写入 |
payment.confirmed | 进度 UI 或刻意设计为可逆的操作 | 认定所有链上风险已经过去 |
已验证的 payment.finalized | 记录底层链上转账已经达到最终状态 | 假定订阅结算已经完成 |
end_user_invoice.paid | 记录发票结算事务已经提交 | 判断本次是首次激活还是续期 |
end_user_subscription.activated 或 end_user_subscription.renewed | 幂等开通或续期服务 | 跳过 Webhook 验签或事件去重 |
普通 payment events 描述底层 Payment Order,end_user_invoice.* 和 end_user_subscription.* 才是订阅状态的事实来源。订阅 end_user_invoice.open、.paid、end_user_subscription.activated 和 .renewed;将 end_user_invoice.payment_late 作为需要明确恢复决策的异常处理。催缴流程还应定期查询订阅状态,避免 past_due 和 expired 账户只依赖某一条通知路径。对原始 Webhook body 验签,按 X-Event-Id 去重,并按内部账户或订阅 ID 使真正的权益写入幂等。Webhook 指南和稳定币支付 Webhook说明了验签、重试和履约边界。
明确处理续费、变更和恢复
稳定币计费不包含隐藏的付款权限,因此面向客户的生命周期必须清晰可见。
| 计费情形 | 当前生命周期行为 | 商户动作 |
|---|---|---|
| 首张发票已支付 | 发票变为 paid;订阅发出 activated 并变为 active | 幂等开通首期权益 |
| 首张发票未支付 | 到期且没有在途订单时,发票变为 uncollectible,incomplete 订阅变为 expired | 将记录视为终态,并转入商户自定义的重新入驻或客服流程 |
| 续费发票已开出 | 当前订阅继续有效,付款人收到新发票 | 创建新的 Portal session 和 Checkout 链接;首期付款不构成后续授权 |
| 续费未支付 | 当前周期结束后订阅进入 past_due;超过宽限期后变为 expired,开放发票变为 uncollectible | 执行已说明的宽限期和访问策略,并定期对账状态 |
| 超过收款期后到账 | 不可收取发票保持未结算,并发出 end_user_invoice.payment_late | 通过明确的客服流程决定复核、退款、记账或恢复服务 |
| 升级到更高价格套餐 | 立即开出金额为目标价减当前价的 upgrade_proration 差价发票;结算后才切换套餐 | 展示并收取差价发票 |
| 降级或同价变更 | 目标套餐保持 pending,到下一次续费时生效 | 展示未来生效日期 |
| 期末取消 | 设置 cancelAtPeriodEnd;进入终态前仍可恢复 | 按产品策略保留权益直到当前周期结束 |
| 立即取消 | 开放发票变为 uncollectible,订阅变为 canceled | 停止权益;不要假定 resume 可以将其复活 |
升级或取消时,应使用订阅 API 或 Merchant Portal,而不是只修改内部套餐标签。resume 只能清除非终态订阅上待执行的期末取消,不能复活已经 canceled 或 expired 的订阅。应用应保留 StableOps subscription ID、当前套餐、invoice ID 和 payment-order reference,使客服和对账能定位到准确的计费周期。Merchant Portal session只为终端用户提供其自身订阅和发票的受限上下文,不能替代后端授权规则。
USDC 订阅支付的生产检查清单
- 将产品描述为付款人主动发起的周期性 USDC 发票,不描述成自动钱包扣款。
- 在后端创建套餐和订阅;不要在浏览器暴露商户 secret API key。
- 将账户 ID 与 StableOps subscription ID、invoice ID 和关联的 payment-order ID 一起保存。
- 从明确且已支持的 USDC 链和资产组合开始,并保持充足的收款地址容量。
- 将发票金额与返回的 payment instructions 作为付款人转账的事实来源。
- 仅将
successUrl用于用户体验;通过已验签、已去重的订阅事件和对账后的订阅状态结算权益。 - 将续费、past-due、升级、取消和恢复当作明确的状态变更处理。
- 即使 Webhook 已去重,仍要按内部账户或订阅 ID 让权益写入幂等。
- 扩展到更多链或套餐前,对账
open、paid、void、uncollectible发票,以及past_due、expired订阅。
FAQ
USDC 订阅可以自动从客户钱包扣款吗?
不可以,至少本周期性发票流程不支持。每张发票都由客户通过 Hosted Checkout 或其主动发起的钱包流程支付。StableOps 不保管客户私钥、钱包凭据或可复用扣款授权,也不会把首期付款当成以后划转 USDC 的许可。
订阅计费应支持哪一条 USDC 网络?
先选择一组客户常用、产品已支持且你有收款地址的 (chain, asset) 组合。付款人选择的精确付款指令,包括链、USDC 资产、地址和金额,才是发票 Payment Order 的匹配依据。
客户付款后,何时续期服务?
首次开通应处理已验签、已去重的 end_user_subscription.activated,续期应处理 end_user_subscription.renewed,并以 API 状态查询作为恢复路径。底层 payment.finalized 证明链上付款达到最终状态,但它可能早于订阅结算完成。不要根据钱包交易哈希、Hosted Checkout 回跳或未经验证的 Webhook 续期。
从一个套餐和一条订阅事件路径开始
先创建一个按月套餐,在 Sandbox 创建订阅,并让测试用户通过 Hosted Checkout 支付第一张发票。随后接通已签名的发票和订阅 Webhook 路径,再向真实客户提供套餐。非托管 USDC 收款架构说明了底层 Payment Order 生命周期。当服务能够收取付款人主动发起的 USDC 转账、通过订阅事件开通或续期,并恢复一次漏付或迟到付款时,就具备了不保管付款人钱包凭据的稳定周期性计费基础。
相关文章
用链级付款指令、精确订单匹配、确认跟踪和已验证的 Webhook 履约,可靠接收 USDT。
通过原始 body 验签、事件去重、幂等履约与可重放恢复,可靠处理稳定币支付 Webhook。
使用支付订单、链上监听、最终性和可靠 Webhook,将 USDC 收入商户控制的钱包。