如何通过 API 监控定期付款状态
结论
可以。成熟的支付服务通常会通过 **Webhook(服务器回调)**持续通知商户定期付款的结果,同时提供查询 API,供商户获取付款时间、状态、金额、订阅 ID 和付款 ID。
常见的实现流程如下:
- 创建订阅,保存支付平台的客户 ID 和订阅 ID。
- 接收支付平台发送的 Webhook。
- 验证 Webhook 签名。
- 根据事件更新本地账单记录。
- 必要时使用付款 ID 调用查询 API 进行对账。
- 通过定时任务补查遗漏的事件。
不要定时查询银行账户,也不能只处理首次付款结果。Stripe、PayPal、Adyen、Braintree 等平台都提供类似能力,但事件名称和字段结构各不相同。选型前需要确认目标平台是否支持订阅、失败通知、签名验证、事件重放和付款明细查询。
Google Checkout 已经停止服务,不能作为当前系统的技术依据。
为什么不能只看首次付款结果
首次付款成功,只能说明订阅创建时完成了一次扣款,不能保证以后每个月都能成功付款。后续付款可能因以下原因失败:
- 银行卡过期或被更换;
- 余额不足或信用额度不足;
- 发卡行拒绝交易;
- 用户撤销授权;
- 强客户认证等额外验证未完成;
- 支付平台正在重试扣款;
- 订阅被暂停、取消或进入欠费状态。
系统需要分别记录“订阅状态”和“每一期付款状态”。这两个状态不能混在一起。即使订阅仍然存在,某一期账单也可能处于失败或重试状态。
推荐的数据模型
本地数据库至少需要保存客户、订阅、账单和支付事件。
customers
- id
- provider
- provider_customer_id
subscriptions
- id
- customer_id
- provider_subscription_id
- status
- current_period_start
- current_period_end
- canceled_at
payments
- id
- subscription_id
- provider_payment_id
- provider_invoice_id
- amount
- currency
- status
- paid_at
- failed_at
- failure_code
- failure_message
payment_events
- provider_event_id
- event_type
- received_at
- processed_at
- payload
支付平台可能重复发送同一个 Webhook,因此应为 provider_event_id 设置唯一约束。付款记录也应按照平台付款 ID 设置唯一约束,避免重复入账。
通过 Webhook 监控付款
各个平台使用的事件名称并不统一。以 Stripe 常见的订阅流程为例,通常需要关注以下事件:
invoice.paid:账单已支付;invoice.payment_failed:账单付款失败;invoice.payment_action_required:付款需要客户进一步操作;customer.subscription.updated:订阅状态发生变化;customer.subscription.deleted:订阅结束;charge.refunded:已付款项发生退款。
有些集成还会处理 payment_intent.succeeded 和 payment_intent.payment_failed。系统应根据自身的记账方式,选择以“订阅账单”还是“底层支付尝试”为核心。不要把两个层级的事件都直接记成一笔新收入,否则容易重复记账。
下面是一个简化的 Node.js/Express 示例:
import express from "express";
import Stripe from "stripe";
const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
app.post(
"/webhooks/stripe",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (error) {
return res.status(400).send(`Invalid webhook signature: ${error.message}`);
}
try {
// 数据库中应对 event.id 建立唯一约束。
const alreadyProcessed = await hasProcessedEvent(event.id);
if (alreadyProcessed) {
return res.sendStatus(200);
}
switch (event.type) {
case "invoice.paid": {
const invoice = event.data.object;
await recordSuccessfulInvoice({
eventId: event.id,
invoiceId: invoice.id,
subscriptionId: getSubscriptionId(invoice),
paymentId: getPaymentId(invoice),
amount: invoice.amount_paid,
currency: invoice.currency,
paidAt: new Date(invoice.status_transitions.paid_at * 1000)
});
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object;
await recordFailedInvoice({
eventId: event.id,
invoiceId: invoice.id,
subscriptionId: getSubscriptionId(invoice),
paymentId: getPaymentId(invoice),
amount: invoice.amount_due,
currency: invoice.currency,
failedAt: new Date()
});
break;
}
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const subscription = event.data.object;
await updateSubscription({
eventId: event.id,
subscriptionId: subscription.id,
status: subscription.status,
currentPeriodStart: subscription.current_period_start
? new Date(subscription.current_period_start * 1000)
: null,
currentPeriodEnd: subscription.current_period_end
? new Date(subscription.current_period_end * 1000)
: null
});
break;
}
default:
await recordIgnoredEvent(event.id, event.type);
}
res.sendStatus(200);
} catch (error) {
console.error(error);
// 返回非 2xx 状态,让支付平台稍后重试。
res.sendStatus(500);
}
}
);
app.use(express.json());
app.listen(3000);
示例中的 hasProcessedEvent、recordSuccessfulInvoice 等函数需要按照实际使用的数据库实现。getPaymentId() 也不能简单假设某个字段始终存在。平台的数据结构可能因支付流程或 API 版本而变化,应按照当前 SDK 的类型定义和官方文档,提取 PaymentIntent、Charge 或其他交易对象的 ID。
Webhook 处理必须支持幂等
支付平台通常采用“至少投递一次”的机制。如果接口超时、网络中断或返回非成功状态,同一个事件就可能再次发送。
处理事件时,应先插入事件 ID,并通过数据库唯一约束保证操作的原子性。例如:
CREATE TABLE payment_events (
provider_event_id VARCHAR(255) PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
payload JSON NOT NULL,
received_at TIMESTAMP NOT NULL,
processed_at TIMESTAMP NULL
);
事件登记和付款状态更新最好放在同一个事务中完成:
BEGIN;
INSERT INTO payment_events (
provider_event_id,
event_type,
payload,
received_at
)
VALUES (
:event_id,
:event_type,
:payload,
CURRENT_TIMESTAMP
)
ON CONFLICT (provider_event_id) DO NOTHING;
-- 只有成功插入事件时,才继续更新付款记录。
COMMIT;
冲突处理语法取决于使用的数据库,MySQL、PostgreSQL 和 SQL Server 的写法并不相同。
使用查询 API 进行补偿和对账
Webhook 是实时通知渠道,但不应成为唯一的数据来源。服务器停机、配置错误或事件处理程序故障,都可能导致状态遗漏。
可以每天或每小时运行一次对账任务:
- 查询最近一段时间内创建或更新的账单;
- 使用平台账单 ID 或付款 ID 匹配本地记录;
- 补写缺失的记录;
- 修正仍处于
pending、processing等中间状态的记录; - 对无法匹配的退款、拒付和撤销交易发出告警。
以 Stripe 查询单笔 PaymentIntent 为例:
const paymentIntent = await stripe.paymentIntents.retrieve(
providerPaymentId
);
console.log({
id: paymentIntent.id,
status: paymentIntent.status,
amount: paymentIntent.amount,
currency: paymentIntent.currency,
createdAt: new Date(paymentIntent.created * 1000)
});
查询接口适合用于补偿、人工排查和对账,不建议高频轮询每一笔订阅。轮询会增加延迟和 API 调用量,也会带来限流风险,而且难以及时捕获退款、拒付等异步变化。
付款失败后如何处理
收到失败事件后,不应立即永久关闭用户服务。许多支付平台会按照商户的配置自动重试扣款。
较稳妥的状态流程如下:
active
↓ 付款失败
past_due
↓ 重试成功
active
past_due
↓ 多次重试失败或订阅终止
suspended / canceled
付款失败后,业务系统可以先进入宽限期,并提醒客户更新付款方式。只有满足明确的欠费条件后,才暂停服务。最终状态应以支付平台事件和查询结果为准,不能只根据本地计时器判断付款已经彻底失败。
是否可以直接使用银行 API
理论上可以,但银行 API 通常不适合代替支付平台的订阅 API。
银行开放接口更常用于查询账户流水、确认入账,或发起特定类型的银行付款。银行流水通常只能提供到账金额、时间和摘要,未必能稳定对应到具体客户、订阅周期或付款尝试,也很难完整反映银行卡拒付、自动重试、退款和争议处理过程。
如果业务采用银行转账,可以为每个客户分配唯一的虚拟账号或付款附言,再通过银行通知或流水 API 完成匹配。即使这样,仍然需要自行建设:
- 付款与账单匹配;
- 重复付款识别;
- 少付和多付处理;
- 退款流程;
- 定期扣款授权管理;
- 对账和异常处理。
对于银行卡或电子钱包订阅,通常更适合使用支持订阅和 Webhook 的支付服务。
上线时需要特别注意
- 必须验证 Webhook 签名,不能只凭请求中的事件 ID 或付款状态入账。
- Webhook 地址应使用 HTTPS。
- 不要在 Webhook 中执行耗时操作。可以先持久化事件,再交给队列异步处理。
- 不要依赖事件的到达顺序。旧事件可能晚于新事件到达,更新状态时应检查事件时间和当前记录。
- 金额应使用支付平台返回的最小货币单位,例如分,不要使用浮点数。
- 不要存储完整银行卡号或安全码。支付信息应交由支付平台托管或进行令牌化处理。
- 同时记录订阅 ID、账单 ID 和付款 ID,以便区分一次账单和多次付款尝试。
- 测试成功、失败、超时、重复通知、乱序通知、退款、拒付和取消订阅等情况。
- 定期运行对账任务,并为连续处理失败或长期未决的付款设置告警。
- 字段位置、事件名称和状态枚举可能随平台及 API 版本变化。实施时应以所选服务当前的官方文档为准。
备注:内容仅供参考。
