---
title: "Stripe 支付失败告警：拒付、扣款失败与失效的 Webhook"
description: "Stripe 只会用邮件通知你拒付和支付失败。本文讲清楚如何把 Stripe 的 webhook 事件变成推送、时效性通知或一通电话——包含具体的事件类型、条件和模板。"
date: 2026-09-11
author: Nooc
authorAvatarLink: /images/avatars/nooc.webp
authorLink: https://nooc.me
tags:
  - Stripe
  - 支付
  - Webhook
  - 拒付
  - SaaS
  - 告警
---

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

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

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

## 真正值得打断你的事件

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

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

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

## 你需要准备

- 一个 Stripe 账号，能访问 [Workbench 里的 Webhooks 标签页](https://dashboard.stripe.com/webhooks)
- 装好 Echobell（[App Store](https://apps.apple.com/app/apple-store/id6743597198?pt=128151925&ct=blog-stripe-payment-failure-alerts-zh&mt=8) / [Google Play](https://play.google.com/store/apps/details?id=one.echobell.echobellandroid)）
- 五分钟。不需要服务器、不需要部署、不需要写代码——除非你想要签名校验，那部分在最后一节。

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

很容易想做一个叫 "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>`。

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

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

在 Stripe 后台打开 [Webhooks 标签页](https://dashboard.stripe.com/webhooks)，创建一个事件目标：

<Steps>

<Step>

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

</Step>

<Step>

只勾选一种事件类型——这个频道选 `charge.dispute.created`。Stripe 自己的建议就是[只订阅你的集成真正需要的事件](https://docs.stripe.com/webhooks)；在这里它还能让模板保持诚实，因为到达的每个负载都是同一种结构。

</Step>

<Step>

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

</Step>

<Step>

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

</Step>

</Steps>

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

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

Stripe 会把 [Event 对象](https://docs.stripe.com/api/events/object)以 JSON 形式发过来。Echobell 原样读取请求体，所以每个字段都能在模板和条件里用点号访问：

```json
{
  "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 }
    }
  }
}
```

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

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

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

**字段名因对象而异。** 拒付有 `amount`；账单有 `amount_due`、`customer_email`、`attempt_count` 和 `hosted_invoice_url`；打款有 `failure_message` 和 `arrival_date`；早期欺诈预警有 `fraud_type`、`actionable`，以及作为纯字符串 ID 的 `charge`。缺失的变量会渲染成空字符串而不是报错，所以从错误频道复制来的模板会悄无声息地失效。这正是"每种事件一个频道"的现实理由。

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

频道[条件](/zh/docs/conditions)使用和模板相同的表达式语法，只是不带花括号，并且在任何投递之前执行。

每个 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 会[以指数退避重试最长三天](https://docs.stripe.com/webhooks)并给你发一封邮件——而一封关于 webhook 未送达的邮件，长得和其他所有 Stripe 邮件一模一样，所以它往往要到周一才被翻出来。

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

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

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

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

## 可选 —— 先校验签名

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

```js
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 [不保证事件顺序](https://docs.stripe.com/webhooks)，同一个事件也可能投递多次。一次拒付响两下是有可能的。
- **没有确认回执。** 没有任何记录说明有人看到了它，也没有任何机制在无人响应时升级给第二个人。
- **没有"支付停了"的告警。** Stripe 只在事情发生时发事件，事情停止时不发。如果你的结账流程坏了，根本不会有任何事件触发。这一类需要你自己那边跑一个定时任务：当上一小时的扣款数为零时去 ping 一个频道——也就是[基于 cron 的看门狗](/zh/blog/cron-job-failure-alerts)。

## 排查

**Stripe 里显示 `200`，但没收到通知。** 即使没有投递，Echobell 也会带着 JSON 响应体返回 `200`——在 Event deliveries 标签页里看响应体。长度合法的 token 配上 `success: false`，说明频道 token 不对。如果 `success` 是 `true`，最可能的原因是条件：`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`。

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

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

### 该给成功的支付设告警吗？

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

## 小结

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

[在 iPhone 上下载 Echobell](https://apps.apple.com/app/apple-store/id6743597198?pt=128151925&ct=blog-stripe-payment-failure-alerts-zh&mt=8) 或[在 Google Play 获取](https://play.google.com/store/apps/details?id=one.echobell.echobellandroid)，先把拒付频道建起来，在把任何真正重要的事情托付给这条链路之前，先发一次 `stripe trigger charge.dispute.created`。

---

## 相关内容

- [Webhook 集成文档](/zh/docs/webhook)
- [频道条件参考](/zh/docs/conditions)
- [开发者的告警疲劳修复指南](/zh/blog/fix-alert-fatigue-developer-guide)
- [定时任务失败告警](/zh/blog/cron-job-failure-alerts)
- [把 Zapier webhook 通知发到手机](/zh/blog/zapier-webhook-notifications-to-phone)
