Stripe 指南

如何在本地测试 Stripe Webhook

掌握实用的 Stripe Webhook 测试流程:捕获真实投递、检查原始载荷、转发到 localhost,以及在改代码后重放已保存事件。

要在本地测试 Stripe Webhook,需要把 Stripe 的事件投递经由稳定的公网 URL 路由,捕获每次请求以便检查,再把同一次投递转发到你本机的开发服务器。这样你能看到 Stripe 在代码处理之前发出的确切载荷,从而消除 Webhook 调试中大量猜测。

最快路径是:用 HookNexus 创建公网端点,把该 URL 填入 Stripe 控制台,触发测试事件,确认投递已落库,再用 CLI 把流量管道式送到本地路由。本指南逐步说明全流程,并包含正确校验 Stripe 签名所需的 Express.js 处理代码。

为什么本地 Stripe Webhook 测试比想象中难

Stripe 只能向公网可达的 URL 投递 Webhook。你的 localhost:3000 开发服务器不满足条件;若强行填写,Stripe 会静默失败或返回连接错误。仅此一条约束就会连带出一串让资深开发者也踩坑的问题。

首先,Stripe 用 Stripe-Signature 头中的 HMAC 对每次投递签名,签名基于原始请求体计算。若任何中间件在验证签名前解析或变换正文,校验会以令人困惑的 stripe.webhooks.constructEvent 错误失败。这是开发者最常遇到的问题,且与端点 URL 本身无关。

其次,Stripe 的测试模式正式模式完全隔离,各有 API 密钥、Webhook 签名密钥与事件流。若在查看正式模式控制台时注册了端点,却在测试模式触发事件,端点永远收不到投递。控制台对这种错配提示得不够醒目。

第三,即便投递成功,改代码后仍需要重放完全相同的事件。重新触发 checkout.session.completed 意味着新建 Checkout Session、走完流程、再等 Stripe 发事件。在迭代调试中,这种开销会迅速累积。

一套合理的本地测试方案同时解决三件事:可接流量的公网 URL、为签名校验保留原始正文、以及可 按需重放 的捕获历史。

开始之前需要准备

开始前请确认:

  • 拥有可进入测试模式Stripe 账户(无需正式模式即可完成本指南)。
  • 已有 Stripe Webhook 签名密钥(以 whsec_ 开头),在注册端点后于 Stripe 控制台可见。
  • 本机已安装 Node.js 18+(用于 Express 示例)。
  • 已全局安装 HookNexus CLI
npm install -g hooknexus
hooknexus login
  • 本地有服务在已知端口监听(本指南假设为 http://localhost:3000/api/webhooks/stripe)。

若尚无 HookNexus 账户,免费额度足以跑通全流程。可通过 Webhook 调试器 注册。

分步本地测试流程

本节从创建端点到在应用代码中处理事件,覆盖完整链路。

第一步 —— 创建公网 Webhook 端点

在终端用 HookNexus CLI 新建端点:

hooknexus endpoints create

CLI 会返回形如 https://api.hooknexus.com/h/abc123 的 URL,复制它。这是 Stripe 将投递事件的稳定公网地址。命中该 URL 的每次请求都会被记录,便于稍后在 HookNexus 调试器 中查看。

也可在 Web 控制台创建端点,若你更习惯图形界面。

第二步 —— 在 Stripe 控制台注册端点

  1. 打开 Stripe 控制台,确认处于测试模式(右上角开关)。
  2. 进入 Developers > Webhooks
  3. 点击 Add endpoint
  4. 将 HookNexus 端点 URL 粘贴到 Endpoint URL
  5. Select events to listen to 中,选择应用实际需要的事件。结账类集成常见选项包括:
    • checkout.session.completed
    • invoice.paid
    • invoice.payment_failed
    • customer.subscription.updated
    • customer.subscription.deleted
  6. 点击 Add endpoint 保存。

保存后进入端点详情页,在 Signing secret 下点击 Reveal,复制 whsec_...。应用校验签名时需要此值。

除非处理逻辑明确需要,否则避免选择「全部事件」。事件过多会增加调试噪音,难以聚焦关心的载荷。

第三步 —— 触发测试事件

保持在测试模式,执行能触发已选事件的操作。对 checkout.session.completed 而言,较简便的方式是:

  1. 用 Stripe API 或应用结账流程创建 Checkout Session。
  2. 使用 Stripe 测试卡(如 4242 4242 4242 4242)完成支付。
  3. 支付成功后,Stripe 会向已注册端点发送 checkout.session.completed

也可在 Stripe 控制台端点详情页使用 Send test webhook,发送带示例数据的合成事件。适合验证连通性,但载荷结构可能与真实事件略有差异。

第四步 —— 查看已捕获的请求

打开 HookNexus 控制台,或用 CLI 查看端点请求历史:

hooknexus endpoints list

查看捕获的请求并确认:

  • 状态码:HookNexus 向 Stripe 返回 200,表示投递成功。
  • Headers:存在 Stripe-Signature(HMAC)、Content-Type: application/jsonStripe-Event-Id
  • Body:JSON 内含事件对象;type 与预期一致(如 checkout.session.completed),data.object 为预期资源。
  • 时间:若同一事件有多条记录,可能是此前某次返回非 2xx,Stripe 在重试。

这一步很关键。若请求从未到达,问题在 Stripe 到端点的链路(URL 错误、模式错误、网络),而非本地代码。应先修复投递,再调试处理程序。

需要更完整的说明可参考 HookNexus Stripe 集成文档

第五步 —— 转发到 localhost

确认投递正常后,将实时流量转发到本地服务器。高级选项见 CLI 转发文档

hooknexus forward ENDPOINT_ID --to http://localhost:3000/api/webhooks/stripe

ENDPOINT_ID 换为第一步中的实际 ID。CLI 会保持长连接,把每次入站请求实时管道到本地路由,终端会打印转发的请求。

开发期间保持该终端会话运行,新的 Stripe 投递会自动转发。若本地服务未启动或返回错误,HookNexus 仍会保存原始请求,便于稍后 重放

转发流程的背景说明见 将 Webhook 转发到 localhost 指南。

第六步 —— 在应用中处理 Webhook

下面是一个完整的 Express.js Webhook 处理示例:校验 Stripe 签名并处理事件。必须stripe.webhooks.constructEvent 传入原始请求体,而非解析后的 JSON。

const express = require("express");
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);

const app = express();

app.post(
  "/api/webhooks/stripe",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["stripe-signature"];
    const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

    let event;

    try {
      event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret);
    } catch (err) {
      console.error("Signature verification failed:", err.message);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    switch (event.type) {
      case "checkout.session.completed":
        const session = event.data.object;
        console.log("Checkout completed for session:", session.id);
        // Fulfill the order, update database, send confirmation email
        break;

      case "invoice.paid":
        const invoice = event.data.object;
        console.log("Invoice paid:", invoice.id);
        // Record successful payment, extend subscription
        break;

      case "invoice.payment_failed":
        const failedInvoice = event.data.object;
        console.log("Payment failed for invoice:", failedInvoice.id);
        // Notify customer, flag account
        break;

      default:
        console.log("Unhandled event type:", event.type);
    }

    res.status(200).json({ received: true });
  }
);

app.listen(3000, () => {
  console.log("Webhook handler listening on port 3000");
});

关键一行是 express.raw({ type: "application/json" }),它确保正文以原始 Buffer 进入处理函数。若全局使用 express.json(),会在 Webhook 路由之前解析正文,签名验证将始终失败。深入说明见 Stripe Webhook 签名验证失败排查

若使用 Next.js App Router 而非 Express,对 Webhook 路由应禁用正文解析:

// app/api/webhooks/stripe/route.js
import Stripe from "stripe";
import { NextResponse } from "next/server";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

export async function POST(request) {
  const body = await request.text();
  const sig = request.headers.get("stripe-signature");

  let event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    console.error("Signature verification failed:", err.message);
    return NextResponse.json(
      { error: `Webhook Error: ${err.message}` },
      { status: 400 }
    );
  }

  switch (event.type) {
    case "checkout.session.completed":
      console.log("Checkout session completed:", event.data.object.id);
      break;
    case "invoice.paid":
      console.log("Invoice paid:", event.data.object.id);
      break;
    default:
      console.log("Unhandled event type:", event.type);
  }

  return NextResponse.json({ received: true });
}

Next.js App Router 在调用 request.text() 时不会自动解析正文,因此签名验证通常无需额外配置。

本地 Stripe 测试中的常见问题

签名验证失败

几乎总是因为正文解析中间件在 Webhook 路由之前运行。Stripe SDK 根据请求体原始字节计算预期签名;若 express.json() 等解析器把正文变成对象再序列化,字节表示变化,签名不再匹配。

修复:在 Webhook 路由上单独使用 express.raw({ type: "application/json" }),或确保全局正文解析不作用于 Webhook 路径。Next.js 中用 request.text() 而非 request.json()

完整 walkthrough 见 Stripe Webhook 签名验证失败

事件从未到达端点

按顺序检查:

  1. 模式不一致:控制台与触发操作须同为测试或同为正式。
  2. URL 笔误:Stripe 中的端点 URL 须与 HookNexus 端点 URL 完全一致。
  3. 事件筛选:确认你触发的事件类型包含在端点已选事件中。
  4. Stripe 投递日志:在控制台打开该端点,查看 Webhook attempts 标签中的状态与错误信息。

事件类型不对

若收到的事件 type 与预期不符,确认触发动作是否正确。例如仅创建 Checkout Session 会触发 checkout.session.created,而非 checkout.session.completedcompleted 仅在客户完成支付后触发。

测试模式与正式模式混淆

Stripe 为测试与正式维护完全独立的 Webhook 端点列表。在正式模式注册的端点不会收到测试事件,反之亦然。务必确认控制台右上角模式与 API 密钥、生成的事件模式一致。

Webhook 签名密钥(whsec_...)也按模式区分。用正式密钥验证测试投递,即使正文完整也会失败。

何时用重放而非重新触发

捕获到一次 Webhook 投递后,不必每次改处理代码都回到 Stripe 里重做原始动作。可直接从 HookNexus 重放已捕获请求。重放会把相同的头与正文发到本地路由,相当于对原始投递的像素级复现。

重放特别适用于:

  • 原始触发成本高或耗时长(完整 Checkout 流程)。
  • 迭代错误处理,需要反复命中同一条失败路径。
  • 测试幂等性:同一事件处理多次。

详细步骤见 在本地重放 Stripe Webhook 事件

快速检查清单

  • 已安装并登录 HookNexus CLI(hooknexus login
  • 已用 hooknexus endpoints create 创建公网端点
  • 已在 Stripe 控制台注册 URL(模式正确)
  • 已选择具体事件类型(非「全部事件」)
  • 已触发测试事件并在 HookNexus 确认投递
  • 编写处理代码前已检查原始正文、头与事件类型
  • 已用 hooknexus forward ENDPOINT_ID --to http://localhost:3000/api/webhooks/stripe 启动转发
  • Webhook 处理使用原始正文做签名校验(constructEvent 前不做 JSON 解析)

常见问题

只用 Stripe CLI 够本地测试 Webhook 吗?

Stripe CLI(stripe listen --forward-to)对纯 Stripe 场景很好用:临时隧道并直接转发事件。但会话结束后不持久保存请求历史,改代码后无法从历史重放,且只支持 Stripe。若应用对接多个提供商,或需要持久的检查与重放工作流,专用 Webhook 调试器 更灵活。

为什么不只在 Stripe 控制台里调试?

控制台展示投递尝试与响应码,有助于确认 Stripe 已发出事件。但它无法把请求转发到 localhost、在调试上下文中查看原始正文,也无法把同一次投递重发到开发机。控制台告诉你 Stripe 做了什么;本地调试告诉你 你的代码如何处理

什么时候该把重放加入工作流?

一旦开始对 Webhook 处理做迭代修改,就应加入重放。首次投递确认事件结构与初始逻辑;之后每次代码变更都适合重放——跳过触发步骤,用完全相同载荷跑更新后的处理程序,把反馈周期从分钟级缩短到秒级。

本地测试需要单独的签名密钥吗?

不需要。无论请求直接来自 Stripe 还是经 HookNexus 转发,都使用 Stripe 控制台端点配置中的同一 whsec_...。HookNexus 保留原始头(含 Stripe-Signature),验证逻辑与生产一致。

下一步

你现已具备完整的本地 Stripe Webhook 测试链路:公网端点、事件捕获、检查、localhost 转发与签名验证。接下来可以: