这是 DEV 的 Summer Bug Smash: Smash Stories 的投稿,由 Sentry 赞助。

支付 webhook 听起来很简单,直到成功支付实际上并未带来客户所支付的服务。

那是我在构建 The Listening Ear(一个预约和在线咨询平台)时遇到的更有趣的 bug 之一。

需求很简单:

客户为会话付款 → 应用确认付款 → 客户预约被预订 → 创建 Zoom 会议。

现实要复杂得多。

项目概览

The Listening Ear 将在线支付与预约安排和基于 Zoom 的咨询连接起来。

该应用使用 Next.js 14、TypeScript、Supabase、Prisma、PostgreSQL、Zoom 和支付提供商 API 等技术构建。

支付工作流尤为重要,因为支付确认实际上是后续预约体验的守门人。

预期流程如下:

Customer
   │
   ▼
Payment Provider
   │
   │ webhook
   ▼
Next.js Webhook
   │
   ├── Verify / interpret payment
   │
   ├── Create Zoom meeting
   │
   └── Create appointment record
   │
   ▼
Customer receives access to their scheduled session

Enter fullscreen mode Exit fullscreen mode

问题是 webhook 直接处于所有这些操作的中间。

Bug 修复或性能改进

当我实现将解锁 Zoom 调度工作流的支付 webhook 时,bug 出现了。

我最初的实现监听支付事件并检查事件是否为:

if (event === 'charge.success') {

一旦满足该条件,webhook 立即继续进入预约工作流。

该工作流包括:

  1. 从支付事件中读取预约元数据。
  2. 处理特殊的紧急预约。
  3. 构建 Zoom 会议负载。
  4. 调用 Zoom 会议 API。
  5. 在数据库中创建预约记录。
  6. 向支付提供商返回成功响应。

问题是所有这些操作实际上都与 webhook 请求耦合在一起。

支付 webhook 是一个异步的外部事件。它可能被重试、多次送达,或在系统其他部分仍在处理时到达。

我的实现最初将其过多地视为普通的同步 API 请求。

这造成了瓶颈:

支付确认 → webhook → Zoom API → 预约 API → 响应

如果其中一个下游操作缓慢或失败,webhook 本身可能会失败。

这意味着支付提供商可能会重试 webhook。

然后我可能会再次处理同一个成功的支付。

这才是我需要解决的真正 bug。

调试历程

第一个症状很容易被误解。

用户已成功完成支付,但预期的 Zoom 调度体验并不总是正确完成。

我的第一直觉自然是查看 Zoom 集成。

但沿着请求向后追踪,暴露了一个更有趣的依赖:

Zoom scheduling
      ▲
      │
Appointment creation
      ▲
      │
Payment webhook
      ▲
      │
Payment provider

Enter fullscreen mode Exit fullscreen mode

Zoom API 不一定是最初的问题。

支付 webhook 承担了太多责任。

我开始从传入的支付事件追踪 webhook,历经每个下游操作。

原始实现的相关部分本质上是:

if (event === 'charge.success') {
    // ...

    const res = await axios.post(
        base_url + '/api/create-zoom-meeting',
        zoomPayload
    );

    if (res) {
        const bookedSlotRes = await axios.post(
            base_url + '/api/appointments',
            {
                date: bookingInfo?.appointmentDay,
                time: `${bookingInfo.start}-${bookingInfo.end}`,
                transaction_ref:
                    jsonData?.data?.tx_ref ||
                    jsonData?.data?.reference,
                duration: bookingInfo?.sessionType
            }
        );

        return NextResponse.json({
            message: "Zoom meeting created with booked time slot",
            bookedSlot: bookedSlotRes.data,
            meeting: res.data?.meeting,
            success: true
        });
    }
}

Enter fullscreen mode Exit fullscreen mode

那是我调查的转折点。

webhook 不仅仅是在确认支付。

它还在充当两个额外服务的编排层。

重试问题

问题在这里变得特别有趣。

webhook 端点仅在 Zoom 请求和预约请求完成后才返回成功响应。

因此生命周期实际上是:

Payment Provider
      │
      │ charge.success
      ▼
Webhook
      │
      ├──────► Zoom API
      │          │
      │          ▼
      │       Success?
      │
      ├──────► Appointments API
      │          │
      │          ▼
      │       Success?
      │
      ▼
  HTTP 200

Enter fullscreen mode Exit fullscreen mode

如果在最终响应之前出现问题,处理程序将进入 catch 块:

catch (error: any) {
    console.log("Webhook Error:", error);

    return NextResponse.json({
        message: error.message || error || "Error occured handling webhook",
        success: false
    }, { status: 500 });
}

Enter fullscreen mode Exit fullscreen mode

从应用的角度来看,这是一个合理的错误响应。

但是,从交付 webhook 的支付提供商的角度来看,500 可能意味着:

“接收系统未成功处理此事件。请重试。”

而这正是重试变得危险的地方。

重试可能导致应用再次尝试创建 Zoom 会议。

这引入了从单一支付事件产生重复下游副作用的可能性。

还有另一个问题

在调试 webhook 的同时,我也重新审视了实现中的签名验证部分。

原始代码包含:
const stringifiedBody = request.body?.toString() as string;

const hash = crypto
    .createHmac('sha512', secret_key)
    .update(stringifiedBody, 'utf-8')
    .digest('hex');

Enter fullscreen mode Exit fullscreen mode

然后将生成的哈希与 Paystack 签名进行比较:
request.headers.get('x-paystack-signature')

然而,请求体已在此处被消费:
const jsonData = await request.json();

这使得原始请求体处理变得有问题。

更重要的是,实际的签名检查已被注释掉:

// if (hash === request.headers.get('x-paystack-signature')) {
// }

Enter fullscreen mode Exit fullscreen mode

这是一个重要的提醒,即 webhook 可靠性不仅关乎成功处理事件。

有两个独立的问题:

  1. 此事件是否真实?
  2. 我的应用能否安全可靠地处理它?

两者都很重要。

修复

我围绕更清晰的职责分离重新设计了 webhook 流程。
第一个原则是:

支付 webhook 应在下游服务对其采取行动之前可靠地建立支付状态。

我没有将 charge.success 视为盲目执行整个预约工作流的许可,而是将其视为需要仔细解释和处理的状态转换。

预约元数据继续提供会话所需的信息:

const bookingInfo: any = jsonData?.data?.metadata;

实现还必须考虑特殊的预约类型。

例如,紧急预约要求应用计算新的时间范围:

if (bookingInfo.appointmentOption === "emergency") {
    const { start, end } =
        computeTimeRange(bookingInfo.sessionType, 5);

    startTime = start;

    bookingInfo.start = start;
    bookingInfo.end = end;
}

Enter fullscreen mode Exit fullscreen mode

该信息随后成为 Zoom 会议负载的一部分。

重要的变化不仅仅是修改一个 if 语句。

而是使 payment → booking → Zoom 关系明确且可预测。

使 Webhook 处理更安全

最大的教训是 webhook 处理程序从一开始就应该在设计时考虑重试。

我必须思考诸如以下的问题:

  • 如果同一个成功的支付事件到达两次会怎样?
  • 如果 Zoom 成功但预约创建失败会怎样?
  • 如果预约创建成功但 webhook 响应失败会怎样?
  • 如果提供商在超时后重试事件会怎样?
  • 如何防止重复会议?
  • 如何防止重复预约记录?
  • 何时真正安全地告知支付提供商处理成功?

这些对于 webhook 驱动的系统来说不是不寻常的边缘情况。

它们是正常运行环境的一部分。

因此,最终的方法在支付状态、下游操作和重试行为方面要审慎得多,而不是将 webhook 视为一次性函数。

Zoom 集成只是故事的一半

最有用的调试教训之一是将可见症状与问题的实际来源分开。

用户体验看起来像是 Zoom 问题:

“我付款了,但我无法获得我的会议。”

But the actual chain was:

Payment
   ↓
Webhook
   ↓
Payment state
   ↓
Booking
   ↓
Zoom meeting

Enter fullscreen mode Exit fullscreen mode

这意味着我无法仅通过盯着 Zoom 集成来可靠地修复问题。

支付 webhook 是外部支付提供商的状态变为内部应用状态的系统边界。

一旦该状态被正确处理,工作流的其余部分就更容易推理了。

也让我注意到的一个小细节

webhook 还处理 Zoom 会议的可选受邀者:

if (bookingInfo.invites && bookingInfo.invites !== "") {
    const parsedInvites = bookingInfo.invites.split(",");

    parsedInvites.map((invitee: string) => {
        zoomPayload.settings.meeting_invitees.push({
            email: invitee
        });

        zoomPayload.settings.authentication_exception.push({
            name: invitee.split('@')?.[0],
            email: invitee
        });
    });
}

Enter fullscreen mode Exit fullscreen mode

这是另一个例子,说明为什么 webhook 处理程序会变得欺骗性地复杂。

单个支付事件携带的元数据会影响:

  • 预约时间;
  • 紧急会话行为;
  • Zoom 会议配置;
  • 受邀者;
  • 身份验证例外;
  • 事务引用;
  • 以及数据库预约信息。

webhook 实际上已成为应用几个独立部分之间的桥梁。

这使得保持工作流可预测性变得更加重要。

结果

最终目标很简单:

Successful payment
        ↓
Reliable payment confirmation
        ↓
Correct appointment state
        ↓
Zoom meeting creation
        ↓
Booked session

Enter fullscreen mode Exit fullscreen mode

我没有将支付 webhook 视为简单的通知端点,而是学会了将其视为可靠性边界。

这种视角转变是最大的改进。

我学到的

这个 bug 教会了我一些在后续后端工作中一直牢记的东西:

永远不要将外部事件视为普通的同步函数调用。

webhook 存在于与你的应用不同的世界中。

发送方和接收方不共享相同的执行上下文。网络请求可能失败。响应可能超时。事件可能被重试。下游 API 可能成功,而你的响应可能失败。并且同一事件可能被发送多次。

这意味着健壮的 webhook 处理需要考虑:

  • 身份验证和签名验证;
  • 明确的支付状态;
  • 异步交付;
  • 重试;
  • 幂等性;
  • 重复事件;
  • 部分失败;
  • 下游副作用;
  • 以及事务引用。

最重要的教训不是如何让支付 webhook 工作。

而是学习如何让 webhook 周围的系统在事情不完美时表现得可预测。

而那是我最乐于砸掉的 bug。