PAYATHON 2026

如何通过 API 监控定期付款状态

支付小周

结论

可以。成熟的支付服务通常会通过 **Webhook(服务器回调)**持续通知商户定期付款的结果,同时提供查询 API,供商户获取付款时间、状态、金额、订阅 ID 和付款 ID。

常见的实现流程如下:

  1. 创建订阅,保存支付平台的客户 ID 和订阅 ID。
  2. 接收支付平台发送的 Webhook。
  3. 验证 Webhook 签名。
  4. 根据事件更新本地账单记录。
  5. 必要时使用付款 ID 调用查询 API 进行对账。
  6. 通过定时任务补查遗漏的事件。

不要定时查询银行账户,也不能只处理首次付款结果。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 是实时通知渠道,但不应成为唯一的数据来源。服务器停机、配置错误或事件处理程序故障,都可能导致状态遗漏。

可以每天或每小时运行一次对账任务:

  1. 查询最近一段时间内创建或更新的账单;
  2. 使用平台账单 ID 或付款 ID 匹配本地记录;
  3. 补写缺失的记录;
  4. 修正仍处于 pending、processing 等中间状态的记录;
  5. 对无法匹配的退款、拒付和撤销交易发出告警。

以 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 版本变化。实施时应以所选服务当前的官方文档为准。

备注:内容仅供参考。