支付宝周期扣款上线踩的三个坑:CYCLE_PAY_AUTH 少个 p、扣款窗口只有 5 天、单笔封顶 100 元
做订阅制(会员月付、年付、SaaS 席位)时,国内做自动续费最常用的方案是支付宝周期扣款。文档写得很全,但内容分散:产品码、扣款时间窗、金额上限、解约状态分别写在四个页面,读漏一个,上线后就会收到 40004。
先给结论:周期扣款不是「签一次约、以后随时扣」。它是一份带时间窗和金额上限的授权,商户必须在窗口内、按上限、按次数去扣。超出任何一条,接口都不会给出容易理解的提示。
以下规则与错误码来自支付宝开放平台文档,未做生产实测;沙箱不可用,验证时用 0.01 元最小单跑通签约—扣款—解约。
一、两个产品码,差一个字母
签约和扣款用的不是同一个产品码:
- 签约:
personal_product_code = CYCLE_PAY_AUTH_P(末尾有 P,Personal) - 扣款:
product_code = CYCLE_PAY_AUTH(末尾没有 P)
最容易出错的地方是:把带 P 的值放进扣款接口,或者把不带 P 的值放进签约参数。签约侧报 MERCHANT_AGREEMENT_IS_NOT_EXIST(商户协议不存在)时,提示看起来像「没有签代扣合同」,实际是参数错了。官方 FAQ 明确写了两种原因:商户未签约产品,或 personal_product_code / product_code 设置错误。
签约有两条路径:
- 独立签约:先调
alipay.user.agreement.page.sign,用户跳转到支付宝侧确认,回跳后再扣款。适合「先试用、后付费」。 - 支付并签约:首单用
alipay.trade.app.pay带agreement_sign_params,一次完成首付和签约。适合「开通即付费」。
支付并签约的 biz_content 大致如下:
{
"out_trade_no": "202207151529dk01111",
"total_amount": "0.01",
"subject": "会员连续包月首月",
"product_code": "CYCLE_PAY_AUTH",
"timeout_express": "90m",
"agreement_sign_params": {
"personal_product_code": "CYCLE_PAY_AUTH_P",
"sign_scene": "INDUSTRY|CARRENTAL",
"external_agreement_no": "20220715220701",
"sign_notify_url": "https://example.com/alipay/sign-notify",
"access_params": { "channel": "ALIPAYAPP" },
"period_rule_params": {
"period_type": "MONTH",
"period": "1",
"execute_time": "2026-12-16",
"single_amount": "15.00",
"total_amount": "180.00",
"total_payments": "12"
}
}
}
几个字段的实际含义和字面不太一样:
- 外层
total_amount是首单金额,不计入周期扣款总额(内层total_amount)。 period_rule_params.execute_time是「下次扣款日」,精确到日(yyyy-MM-dd),不是首单时间。period_type=MONTH时,execute_time不允许传 28 日之后的日期(可以传 28),避免 2 月没有 29/30/31 号;period_type=DAY时,period不得小于 7。external_agreement_no是商户侧唯一签约号,同一用户可以有多套签约关系。签约失败后不要复用,每次签约保持唯一。sign_notify_url与notify_url分开时:支付结果发notify_url,签约结果发sign_notify_url。只设notify_url时,两条通知会挤到同一个地址,处理端必须靠notify_type区分。
二、坑一:签完约不等于随时能扣,窗口只有 5 天
接口调用日期并不等于可扣款范围。最常见的线上问题就是:本地能扣、线上扣不动。
官方规则:允许在约定日期及其之前 5 天发起扣款。约定 20 号扣款,只能在 15 号到 20 号之间发;21 号再发就是过期。
配套还有一条:一个周期内只能成功扣款一次。所以自动续费任务不能写成「每天扫一遍所有到期协议,谁到期就扣」;同一周期内重复扣款会被拒。如果第一次扣款返回「等待用户付款」(10003),第二天的重试仍落在同一周期,仍然只有一次成功机会。
可落地的做法是把扣款时间算成「窗口」,而不是「日期」:
// deductWindow 返回本次周期允许发起扣款的日期区间(含首尾)
func deductWindow(executeTime time.Time) (from, to time.Time) {
to = time.Date(executeTime.Year(), executeTime.Month(), executeTime.Day(), 0, 0, 0, 0, time.Local)
from = to.AddDate(0, 0, -5)
return from, to
}
调度任务每天跑一次,只处理 today 落在 [from, to] 内的协议;窗口外直接跳过,不提前扣,也不延期扣。提前扣会被拒,延期扣同样会被拒,而且报错语义并不指向时间。
三、坑二:单笔 100 元封顶,年付订阅走不通
周期扣款的额度限制(官方 FAQ 标准):
- 单笔不超过 100 元
- 当日累计不超过 1000 元
- 当月累计不超过 30000 元
- 扣款周期最短 7 天,最长不限
也就是说,199 元/年的会员,周期扣款这条产品线本身就走不通。只把 single_amount 调大并不能解决问题:它只是你声明的单次上限,实际扣款还要落在产品额度内。
金额超限时要换产品:资金预授权(先冻结、后转支付),由商户在自己的周期内调授权转支付接口完成扣款。官方 FAQ 对额度超 100 元的建议很直接:核实预授权是否可用且满足业务需求。
选型判断:
| 场景 | 可用产品 | 说明 |
|---|---|---|
| 月付 ≤100 元、周期 ≥7 天 | 周期扣款 | 文档最全,接入成本最低 |
| 单笔 >100 元 或 周期性大额 | 资金预授权 + 授权转支付 | 需单独申请,冻结/解冻与退款链要自己管 |
| 一次性支付、不需要续费 | 普通 alipay.trade.* | 别为了「以后可能加续费」提前上代扣 |
还有一处容易忽略:周期扣款不支持沙箱。本地联调只能请求生产环境,用最小金额(0.01)走完整链路,测试账号与真实账户分开。这一点和微信支付 v3 没有沙箱是同类问题,站内之前对比过四家的测试环境。
四、坑三:解约之后查不到 UNSIGN,通知是唯一信号
协议状态只有两个取值:NORMAL(正常)和 UNSIGN(解约)。
但 UNSIGN 只可能从异步通知里看到(notify_type=dut_user_unsign)。用户自己在支付宝里解约(我的 > 设置 > 支付设置 > 免密支付/自动扣款)之后,再调 alipay.user.agreement.query,返回的是 USER_AGREEMENT_NOT_EXIST(用户协议不存在),不会返回 status=UNSIGN。
这意味着两件事:
- 本地不能把「查不到协议」当成「数据错乱」,它等价于已解约,应该立即停止续费并降级权益。
- 如果
sign_notify_url收不到(域名换了、证书过期、返回非 success),你就不会知道用户解约了,只能靠扣款失败反推。而扣款失败的sub_code是ACQ.AGREEMENT_NOT_EXIST(协议不存在,可能原因:用户已解约或协议号传错),这两种原因在报错里分不开。
所以签约通知地址必须长期稳定,处理端要先把 status + notify_type 落库,再返回 success。返回非 success 会触发支付宝重发,但中间状态仍然会漏。
五、同步返回码怎么读
alipay.trade.pay 的 code 有四种状态:
| code | 含义 | 处理 |
|---|---|---|
| 10000 | 支付成功 | 落库,发权益 |
| 40004 | 支付失败 | 记 sub_code,按原因分类(协议/额度/参数) |
| 10003 | 等待用户付款 | 等 5 分钟后调 alipay.trade.query 查 |
| 20000 | 未知异常 | 同上,先查单再决定 |
10003 和 20000 都要求「等 5 分钟后查单,查 4 次仍是 WAIT_BUYER_PAY 再决定」。此时有两条路:幂等重试代扣,或者调 alipay.trade.cancel 撤销这笔交易,再下一周期重来。不要在原单号上盲目连续请求;一个周期只允许一次成功扣款,重试要带同一个 out_trade_no 做幂等。
两个高频 sub_code:
ACQ.AGREEMENT_ERROR(协议信息异常):一是接口并发导致(文档建议报错后隔几秒再调),二是传的auth_code不属于该product_code下的协议号。扣款场景固定scene=deduct_pay,auth_code=agreement_no。MERCHANT_AGREEMENT_IS_NOT_EXIST(商户协议不存在):商户侧没签约周期扣款产品,或产品码写错(见第一节)。
六、上线前逐条对一遍
- 应用已上线、已绑定并签约「周期扣款」产品;用官方配置检测工具过一遍。
- 签约与扣款的产品码分别是
CYCLE_PAY_AUTH_P/CYCLE_PAY_AUTH,代码里不做字符串拼接。 execute_time按周期类型校验:MONTH不超 28 号,DAY的period≥7。- 扣款任务按「窗口」调度(约定日 -5 到约定日),窗口外不发请求。
- 单笔金额 ≤100、单日 ≤1000、单月 ≤30000,超限的套餐换预授权。
- 签约号
external_agreement_no每次签约全新生成,失败不复用。 - 签约通知与支付通知分开落库,靠
notify_type区分,返回 success 前先持久化。 - 10003/20000 走「5 分钟 + 查单 4 次」,之后才幂等重试或撤销。
- 查协议返回
USER_AGREEMENT_NOT_EXIST时按解约处理,立即停续费。
本文规则与错误码来自支付宝开放平台文档(周期扣款接入指南、如何查询 agreement_no、ACQ.AGREEMENT_ERROR、MERCHANT_AGREEMENT_IS_NOT_EXIST、周期扣款常见问题),金额与时间窗以接入时文档为准;文中未做生产实测,沙箱不可用,验证请用 0.01 元最小单跑通签约—扣款—解约三段。
参考文档: