Stripe 支付失败告警:拒付、扣款失败与失效的 Webhook

Stripe 只会用邮件通知你拒付和支付失败。本文讲清楚如何把 Stripe 的 webhook 事件变成推送、时效性通知或一通电话——包含具体的事件类型、条件和模板。

更新于

目录

拒付发起、订阅续费失败、打款退回——这些 Stripe 都第一时间知道,然后用邮件告诉你。想要更响一点的提醒,就在 Stripe 里再注册一个 webhook 端点,指向 Echobell 频道的 URL,只订阅少数几种事件类型,并按每种事件背后的"截止时间"来选择通知类型。拒付和早期欺诈预警是有倒计时的;一次续费失败通常没有。

本文会讲清楚:哪些 Stripe 事件值得打断你、如何用大约五分钟把 Stripe 接到 Echobell、每个对象上真正存在的模板字段,以及跳过签名校验时你所接受的取舍。

真正值得打断你的事件

支付告警出错的方式几乎千篇一律:有人觉得 payment_intent.succeeded 看着舒服就订阅了它,于是手机一天震四十次,六周后一条拒付通知就这么从眼前滑过去、没人点开。换个起点:从截止时间出发。如果晚八个小时知道也没有任何代价,那它就不需要在八秒内送到你面前。

事件为什么重要建议的通知类型
charge.dispute.created你只有有限的应诉窗口——通常是 7 到 21 天,取决于卡组织。错过就自动判输。电话
radar.early_fraud_warning.created发卡行已经告诉 Stripe 这笔扣款可能存在欺诈。在它升级为正式拒付之前退款,是你还能做的动作,而窗口很短。电话
payout.failedStripe 收到的钱到不了你的银行账户。下游的一切——发薪、现金流测算——现在都是错的。电话
invoice.payment_failed非自愿流失。它自己恢复的概率不低,打电话过头了,但金额最大的那些账户值得当天看一眼。时效性
customer.subscription.deleted自愿流失。今天知道就够了,不值得把你叫醒。普通
payment_intent.succeeded什么都没坏。正是这个事件在训练你忽略上面另外五个。不通知

这些层级对应 Echobell 的三种通知类型普通是一条普通推送,时效性能穿透大多数专注模式,电话则表现为一通来电,可以在勿扰模式下直接响铃。每位订阅者在每个频道上各自选择级别,所以联合创始人可以把拒付设成电话,而支持同事把它设成推送。

你需要准备

第一步 —— 每种事件一个频道

很容易想做一个叫 "Stripe" 的频道,然后把所有事件都发进去。别这么做。拒付、账单和打款各自携带的对象不同,正文模板和后台链接也就不一样——而这套配置的全部意义,恰恰在于让通知本身就说清楚发生了什么,不用点开任何东西。

按事件命名创建频道:Stripe 拒付。把标题和正文模板写成在锁屏上一眼能读完的样子:

Title: 🔴 Dispute opened — {{data.object.reason}}
Body: Amount: {{data.object.amount}} {{data.object.currency}}
Charge: {{data.object.charge}}
Status: {{data.object.status}}

在高级设置里配置链接模板,这样通知记录会打开正确的页面:

https://dashboard.stripe.com/disputes/{{data.object.id}}

然后以电话类型订阅自己,并从频道详情页复制 webhook URL。它长这样:https://hook.echobell.one/t/<channel-token>

在频道高级设置中打开 POST Only。Stripe 永远用 POST 发送,而这个开关意味着把 URL 粘进聊天窗口或浏览器地址栏,不会再触发一条假的拒付告警。

第二步 —— 把 Stripe 指向这个频道

在 Stripe 后台打开 Webhooks 标签页,创建一个事件目标:

点击 Create an event destination,选择 Your account,API 版本保持账号默认。

只勾选一种事件类型——这个频道选 charge.dispute.created。Stripe 自己的建议就是只订阅你的集成真正需要的事件;在这里它还能让模板保持诚实,因为到达的每个负载都是同一种结构。

目标类型选 Webhook endpoint,粘贴 Echobell 频道 URL。

保存,然后用 Send test event——或者命令行里的 stripe trigger charge.dispute.created——确认手机真的响了。

对你创建的每个频道重复一遍。Stripe 每个账号最多允许 16 个 webhook 端点,按告警层级一个一个来完全够用。

第三步 —— 实际收到的是什么

Stripe 会把 Event 对象以 JSON 形式发过来。Echobell 原样读取请求体,所以每个字段都能在模板和条件里用点号访问:

{
  "id": "evt_1P...",
  "type": "charge.dispute.created",
  "livemode": true,
  "created": 1757548800,
  "data": {
    "object": {
      "id": "dp_1P...",
      "amount": 4900,
      "currency": "usd",
      "reason": "fraudulent",
      "status": "needs_response",
      "charge": "ch_3P...",
      "evidence_details": { "due_by": 1759449600 }
    }
  }
}

这个负载里有三点常常让人意外:

金额是以货币最小单位表示的整数。 amount4900 表示 $49.00。Echobell 模板会插值和比较,但不做算术运算,所以 {{data.object.amount}} 渲染出来就是 4900。要么在标签上写清楚(Amount: 4900 (cents)),要么用最后一节的转发器在发送前先除以 100。

时间戳是 Unix 秒。 {{data.object.evidence_details.due_by}} 渲染出来是 1759449600,不是日期。如果比起具体几点,你更在乎"有截止时间"这件事,就把它从模板里去掉——拒付页面上写着——让链接模板去干活。

字段名因对象而异。 拒付有 amount;账单有 amount_duecustomer_emailattempt_counthosted_invoice_url;打款有 failure_messagearrival_date;早期欺诈预警有 fraud_typeactionable,以及作为纯字符串 ID 的 charge。缺失的变量会渲染成空字符串而不是报错,所以从错误频道复制来的模板会悄无声息地失效。这正是"每种事件一个频道"的现实理由。

第四步 —— 用条件过滤,而不是靠意志力

频道条件使用和模板相同的表达式语法,只是不带花括号,并且在任何投递之前执行。

每个 Stripe 频道都该设的一条:

livemode == true

测试模式的流量——你自己跑的 stripe trigger、同事在沙箱里的折腾——从此不会再打到你手机上。这一条应该在确认链路已经打通之后再加,而不是之前。

对失败账单的频道来说,加一个阈值能把小额账户挡在你的晚上之外:

livemode == true && data.object.amount_due > 20000

以分为单位,读作"超过 $200"。而如果你更想看到真正卡住的重试,而不是每一次首次扣款失败:

livemode == true && data.object.attempt_count > 1

如果你确实把一个端点的多种事件类型指向了同一个频道,条件也能把它们重新拆开:

type == "charge.dispute.created" || type == "payout.failed"

第五步 —— 让告警链路待在会坏掉的东西之外

下面这部分比模板值钱。

你生产环境的 webhook 端点是履约发生的地方:它开通权限、写数据库、发收据邮件。也正因如此,它就是应用挂掉时一起挂掉的那个端点。一旦它挂了,Stripe 会以指数退避重试最长三天并给你发一封邮件——而一封关于 webhook 未送达的邮件,长得和其他所有 Stripe 邮件一模一样,所以它往往要到周一才被翻出来。

Webhook 无声死亡的原因都很平淡。Stripe 把 3xx 重定向视为失败,所以一个开始把 http 跳转到 https、或者给 URL 补上末尾斜杠的端点,就收不到事件了。它要求 TLS 1.2 及以上,所以一张过期或配置错误的证书就够了。上周谁加的一条 WAF 规则返回 403,同样够。

一个直接指向 Echobell 的第二端点,不与上述任何一项共命运。它是另一个主机上的另一个 URL,用的是另一张证书,在你的应用躺下时它照样响。这条规则可以推广:告诉你东西坏了的那条路径,不该穿过那个坏掉的东西。

你仍然希望自己端点的失败是可见的。感觉不对劲时,去 Workbench 的 Event deliveries 标签页看看——它按事件显示 DeliveredPendingFailed,以及每次尝试的 HTTP 状态码。Stripe 允许在后台重发 15 天内的事件,用命令行 stripe events resend 则是 30 天,所以两周内发现的缺口是可以补回来的。

Echobell 频道 URL 是一个 bearer 凭证:拿到它的人就能触发这个频道。把 Stripe 直接指向它,意味着没有任何环节校验 Stripe-Signature 头,所以 URL 泄露带来的是一台假告警机器,而不是数据泄露。别把它放进代码仓库和截图里,万一流出就用 Reset Token;如果这个取舍让你不舒服,请看下一节。

可选 —— 先校验签名

如果你希望 Stripe 的签名真的被校验,并且金额按钱的格式显示,那就在前面加一个小转发器。下面这个 Cloudflare Worker 会校验事件、按 Stripe 的要求立即返回 200,再给 Echobell 发一个扁平的负载:

import Stripe from "stripe";

export default {
  async fetch(request, env, ctx) {
    const stripe = new Stripe(env.STRIPE_SECRET_KEY);
    const body = await request.text();

    let event;
    try {
      event = await stripe.webhooks.constructEventAsync(
        body,
        request.headers.get("stripe-signature"),
        env.STRIPE_WEBHOOK_SECRET,
      );
    } catch {
      return new Response("invalid signature", { status: 400 });
    }

    const invoice = event.data.object;
    ctx.waitUntil(
      fetch(env.ECHOBELL_HOOK_URL, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          customer: invoice.customer_email || invoice.customer,
          amount: (invoice.amount_due / 100).toFixed(2),
          currency: invoice.currency.toUpperCase(),
          attempt: invoice.attempt_count,
          externalLink: invoice.hosted_invoice_url,
        }),
      }),
    );

    return new Response("ok", { status: 200 });
  },
};

另一头的模板也好看多了,因为整形工作已经在代码里做完了:

Title: 💳 Payment failed — {{currency}} {{amount}}
Body: Customer: {{customer}}
Attempt #{{attempt}}

externalLink 是一个特殊变量:在没有设置链接模板时,Echobell 会用它作为通知记录的链接,于是托管账单页面一点即达。

注意这里取舍的形状:你现在多了一个自身也会失败的基础设施,而这正是第五步警告的事情。一个合理的折中是:在量大、假告警会很烦人的频道上做签名校验,而把拒付频道直接连线——在那里,误响一次的代价是困惑地看一眼,漏响一次的代价是被拒付的金额。

这套配置不提供什么

  • 没有值班轮换和升级策略。 订阅了电话频道的所有人会被同时打通。四个人时这是特性,四十个人时就是问题;到了四十人你需要的是事件管理平台。
  • 没有去重。 Stripe 不保证事件顺序,同一个事件也可能投递多次。一次拒付响两下是有可能的。
  • 没有确认回执。 没有任何记录说明有人看到了它,也没有任何机制在无人响应时升级给第二个人。
  • 没有"支付停了"的告警。 Stripe 只在事情发生时发事件,事情停止时不发。如果你的结账流程坏了,根本不会有任何事件触发。这一类需要你自己那边跑一个定时任务:当上一小时的扣款数为零时去 ping 一个频道——也就是基于 cron 的看门狗

排查

Stripe 里显示 200,但没收到通知。 即使没有投递,Echobell 也会带着 JSON 响应体返回 200——在 Event deliveries 标签页里看响应体。长度合法的 token 配上 success: false,说明频道 token 不对。如果 successtrue,最可能的原因是条件:livemode == true 按设计会拦下每一个测试事件。

Stripe 报 405 Method Not Allowed 频道开了 POST Only,而有东西发了 GET。Stripe 自己永远用 POST,所以这是某个链接预览或浏览器标签页,不是 Stripe。

通知到了但字段是空的。 模板访问错了对象——在账单频道里写了 {{data.object.amount}},而那里的字段叫 amount_due。发一个真实事件,在后台打开它,读一遍 JSON。

用了几周之后投递开始失败。 检查证书,以及 URL 前面的任何重定向。直接使用频道 URL 时这很少见;如果是你自己部署的转发器,那它就是头号嫌疑。

常见问题

拒付发起时,Stripe 能打电话给我吗?

它自己不能。Stripe 会用邮件、后台、charge.dispute.created 事件通知你,如果你用 Stripe Dashboard 应用还会有推送。想要真正响铃,就把这个事件路由到一个订阅类型为电话的频道。

把 Stripe 接到 Echobell 需要写代码吗?

不需要。Stripe 会向任何公网 HTTPS URL 发 JSON,而 Echobell 频道 URL 就是这样一个地址。只有当你想校验 Stripe-Signature 头或者重新格式化金额时,才需要代码。

把第三方 webhook URL 交给 Stripe 安全吗?

这是一个有意识的取舍。Stripe 发送的负载包含客户和支付元数据,而 Echobell 不会永久存储原始 webhook 负载——渲染后的通知留在你的设备上。你放弃的是签名校验:任何知道这个 URL 的人都能给你发一条以假乱真的告警。把它当 API key 对待,任何你更想校验的场景就用转发器模式。

为什么告警里显示的是 4900 而不是 $49.00?

Stripe 以货币最小单位的整数发送金额,而 Echobell 模板不做算术运算。要么在模板里标明单位,要么在转发器里先除以 100 再发。

怎么让测试模式的事件不吵醒我?

给频道加上条件 livemode == true。Stripe 会把每一个沙箱事件和 stripe trigger 事件标记为 livemode: false

联合创始人能收到同样的告警而不用买席位吗?

可以。把频道链接分享出去,每位订阅者自己选择通知类型。一个人可以把拒付设成电话,另一个人设成普通推送,而订阅者本身没有按席位收费。

该给成功的支付设告警吗?

只在短期内,而且只在业务小到每一笔都还算"事件"的时候。一旦成功支付的通知变成日常,它就开始侵蚀你对真正重要那些通知的反应——这正是告警疲劳背后的核心机制。

小结

整套配置就是:每个 Echobell 频道对应一个 Stripe 事件目标、一条 livemode == true 条件,以及把电话这一级留给"带倒计时"的事件的克制。拒付和早期欺诈预警有倒计时。一个 $9 套餐的续费失败没有,而假装它有,正是你最后会睡过那个真正有倒计时的事件的原因。

在 iPhone 上下载 Echobell在 Google Play 获取,先把拒付频道建起来,在把任何真正重要的事情托付给这条链路之前,先发一次 stripe trigger charge.dispute.created


相关内容