You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何将Stripe webhook作为中间件集成到Express结账路由中

核心结论

这个思路从HTTP请求的基本逻辑上就走不通,不要浪费时间尝试把webhook和发起结账的接口合并到同一路由。
两者的请求发起方、请求时机完全独立:

  • 你调用stripe.checkout.sessions.create时,用户还没完成付款,接口只会返回Stripe托管支付页的跳转地址,此时根本拿不到支付结果
  • Stripe的webhook是Stripe官方服务器在用户完成支付后,从公网主动向你的后端发送的独立请求,和用户前端发起结账的那个请求不属于同一个连接,你不可能在同一个请求响应周期里把webhook的处理结果直接返回给前端
  • 你当前webhook代码里直接取req.user.cart_id、req.user.user_id本身就是错误的:Stripe发送webhook请求时不会携带你系统的用户登录凭证,根本拿不到登录态。
正确实现方案

第一步:创建Stripe会话时绑定自定义元数据

不要指望webhook请求携带你的用户登录态,在创建checkout session的时候,直接把当前用户ID、购物车ID存在session的metadata字段里,Stripe触发webhook时会把这些元数据原封不动带回来。
修改原有checkout逻辑中的session创建部分:

const session = await stripe.checkout.sessions.create({
  line_items: cart.rows.map((product) => {
    return {
      price_data: {
        currency: "usd",
        product_data: {
          name: product.name,
          images: product.img_urls,
        },
        unit_amount: Math.round(product.price * 100), // 用Math.round避免浮点精度导致的金额错误
      },
      quantity: product.quantity,
    };
  }),
  mode: "payment",
  success_url: `${process.env.FRONTEND_ADDRESS}/order/success?session_id={CHECKOUT_SESSION_ID}`, // 把session_id传给前端成功页
  cancel_url: `${process.env.FRONTEND_ADDRESS}/checkout/cancel`,
  metadata: {
    user_id: user_id,
    cart_id: req.params.cart_id, // 直接从路由参数拿cart_id,不依赖后续请求的登录态
  }
});

res.status(200).json({ checkout_url: session.url });

第二步:保留独立的webhook路由,仅处理订单履约逻辑

webhook路由必须单独保留,不要试图合并到发起结账的路由里,且这个路由不要添加你的用户登录鉴权中间件——请求是Stripe服务器发出的,不是用户端发出的。
修正原有webhook中获取用户ID、购物车ID的逻辑,从事件携带的session数据中取值:

cartsRouter.post(
  "/checkout/webhook",
  express.raw({ type: "application/json" }), // 这个raw解析中间件仅给当前路由使用,不要全局挂载
  async (req, res, next) => {
    const sig = req.headers["stripe-signature"];
    let event;

    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    if (event.type === "checkout.session.completed") {
      const session = event.data.object;
      // 从元数据中取之前存入的用户和购物车信息
      const { user_id, cart_id } = session.metadata;

      try {
        // 加幂等判断,避免Stripe重复推送事件导致重复创建订单
        const existedOrder = await db.query(selectOrderByStripeSessionId, [session.id]);
        if (existedOrder.rows.length) {
          return res.status(200).send("Order already processed");
        }

        const cart = await db.query(selectCartProducts, [user_id]);
        const total_price = cart.rows
          .reduce((acc, item) => acc + parseFloat(item.price) * item.quantity, 0)
          .toFixed(2);

        const order = await db.query(insertOrder, [
          user_id,
          total_price,
          "Complete",
          session.id // 把Stripe会话ID存入订单表,用于幂等校验
        ]);
        const order_id = order.rows[0].order_id;

        await Promise.all(
          cart.rows.map(async (product) => {
            await db.query(insertProductIntoOrder, [
              order_id,
              product.product_id,
              product.quantity,
              product.color,
            ]);
            await db.query(deleteProductFromCart, [
              cart_id,
              product.product_id,
            ]);
          })
        );

        // 此处响应是返回给Stripe服务器的,不是给前端用户的,直接返回200确认即可
        return res.status(200).send("Order processed successfully");
      } catch (err) {
        console.error(err.message);
        return res.status(500).send("Webhook handler failed");
      }
    }

    res.status(200).send();
  }
);

第三步:前端成功页主动查询订单状态

用户支付完成跳转到配置的success_url之后,前端拿URL里的session_id参数,调用单独写的查询接口,后端根据session_id查询对应订单信息,返回给前端展示成功页面即可。
简单的查询接口示例:

// 这个接口正常挂载用户登录鉴权中间件
cartsRouter.get("/checkout/order-status", async (req, res, next) => {
  const { session_id } = req.query;
  const user_id = req.user.user_id;
  try {
    const order = await db.query(selectOrderByStripeSessionId, [session_id]);
    if (!order.rows.length) {
      // webhook可能存在几秒延迟,前端收到这个状态可以间隔2秒轮询几次
      return res.status(202).json({ message: "Order processing, please retry later" });
    }
    // 校验订单归属,防止越权查询
    if (order.rows[0].user_id !== user_id) {
      return res.status(403).json({ message: "Forbidden" });
    }
    return res.status(200).json({ order: order.rows[0] });
  } catch (err) {
    next(err);
  }
});
注意事项
  • 绝对不要在创建session的接口里阻塞等待支付结果,Stripe跳转式支付场景下用户可能停留十几分钟才完成付款,接口早就会触发超时
  • webhook接口一定要做幂等处理,Stripe可能对同一个事件重复推送多次,不做判断会生成重复订单
  • webhook接口不要使用全局的JSON body解析中间件,必须用express.raw()拿到原始请求体,否则签名校验会失败
  • 不要试图让webhook直接给用户浏览器返回响应,webhook的请求发送方是Stripe服务器,和用户浏览器没有直接关联。

内容的提问来源于stack exchange,提问作者Nima Zarei

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.03 03:21:46