如何将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
相关产品推荐
相关产品推荐

