React 前端如何获取 webhook 的支付状态更新?
结论
React 不应直接接收支付平台发送的 webhook。通常应按下面的流程处理:
- 支付平台把状态变化发送到后端的 webhook 接口,例如
POST /webhook/payment-transactions。 - 后端验证 webhook,更新数据库中的支付记录,并尽快返回成功响应。
- React 调用面向前端的查询接口获取状态,例如
GET /api/payment-transactions/:id。 - 等待支付结果时,可以定时轮询。如果需要更及时地显示结果,可以使用 SSE 或 WebSocket。
Webhook 接口和 React 查询接口应分开设计。两者的调用方不同,所需的认证和安全策略也不同。
为什么 React 不能直接通过 Fetch 获取 webhook
Webhook 是服务器之间的 HTTP 回调。支付状态变化后,支付平台会主动向一个可通过公网访问的服务器地址发送请求:
Payment Provider
|
| POST webhook event
v
Backend webhook endpoint
|
| update payment record
v
Database
|
| GET payment status
v
React application
浏览器中的 React 页面无法充当稳定的 webhook 接收端,主要有以下原因:
- 浏览器没有固定且可公开访问的服务器地址。
- 用户可能关闭或刷新页面,也可能断开网络。
- Webhook 通常需要验证签名,而签名密钥不能暴露给前端。
- 如果把支付平台的完整事件直接交给浏览器,可能会泄露敏感信息。
fetch()只能主动发起请求,不能让浏览器自动接收其他服务器发给后端的 webhook。
React 获取的不是原始 webhook 请求,而是后端处理并保存后的支付状态。
推荐的接口设计
可以把接口分为两类:
POST /webhook/payment-transactions
GET /api/payment-transactions/:transactionId
POST /webhook/payment-transactions:
- 只供支付平台调用。
- 验证 webhook 签名。
- 处理重复和乱序事件。
- 更新数据库中的支付状态。
- 不能把普通用户的登录状态作为唯一认证方式。
GET /api/payment-transactions/:transactionId:
- 供 React 调用。
- 检查当前登录用户是否有权查看这笔交易。
- 只返回前端需要的字段。
- 不返回 webhook 密钥、支付凭证或完整的敏感事件内容。
即使两个接口使用相似的 URL,也至少要通过 HTTP 方法进行区分。从职责划分和权限管理的角度看,使用不同路径通常更清楚。
后端处理 webhook
下面以 Express 为例。签名验证方式、请求头名称和状态字段由支付服务商决定,应以服务商文档为准。
import express from "express";
const app = express();
app.post(
"/webhook/payment-transactions",
express.raw({ type: "application/json" }),
async (req, res) => {
try {
const signature = req.headers["payment-signature"];
const event = verifyWebhookSignature({
rawBody: req.body,
signature,
secret: process.env.PAYMENT_WEBHOOK_SECRET,
});
if (await webhookEventExists(event.id)) {
return res.sendStatus(200);
}
await updatePaymentFromWebhook({
eventId: event.id,
providerTransactionId: event.data.transactionId,
status: event.data.status,
occurredAt: event.createdAt,
});
res.sendStatus(200);
} catch (error) {
console.error("Webhook processing failed:", error);
res.sendStatus(400);
}
}
);
签名验证通常依赖原始请求体,所以不能未经确认就先用 express.json() 解析 webhook。不同 SDK 的要求可能不同,需要查看支付服务商提供的验证示例。
数据库中的支付记录可以包含这些字段:
id
user_id
provider_transaction_id
status
created_at
updated_at
last_event_id
provider_updated_at
状态可以是 pending、processing、succeeded、failed 等,实际取值仍应以支付服务商的定义为准。
为 React 提供查询接口
React 查询支付状态时,最好使用后端生成的本地支付记录 ID,不要允许用户随意查询支付平台的交易编号。
app.get("/api/payment-transactions/:transactionId", async (req, res) => {
const userId = req.user.id;
const transactionId = req.params.transactionId;
const transaction = await findPaymentTransaction(transactionId);
if (!transaction) {
return res.status(404).json({
error: "Payment transaction not found",
});
}
if (transaction.userId !== userId) {
return res.sendStatus(403);
}
res.json({
id: transaction.id,
status: transaction.status,
updatedAt: transaction.updatedAt,
});
});
返回内容可以很精简:
{
"id": "pay_123",
"status": "processing",
"updatedAt": "2026-08-31T08:30:00.000Z"
}
用户知道 transactionId,不代表他有权访问对应记录。查询接口仍需验证登录状态和记录所有权,防止越权查询。
React 使用 Fetch 轮询支付状态
对支付完成页来说,轮询通常是最简单可靠的方案。下面的组件每两秒查询一次,收到终态后停止轮询。
import { useEffect, useState } from "react";
const FINAL_STATUSES = new Set([
"succeeded",
"failed",
"cancelled",
]);
export default function PaymentStatus({ transactionId }) {
const [status, setStatus] = useState("pending");
const [error, setError] = useState(null);
useEffect(() => {
if (!transactionId) {
return;
}
const controller = new AbortController();
let timeoutId;
let stopped = false;
async function loadStatus() {
try {
const response = await fetch(
`/api/payment-transactions/${encodeURIComponent(transactionId)}`,
{
method: "GET",
credentials: "include",
signal: controller.signal,
headers: {
Accept: "application/json",
},
}
);
if (!response.ok) {
throw new Error(`Status request failed: ${response.status}`);
}
const transaction = await response.json();
if (stopped) {
return;
}
setStatus(transaction.status);
setError(null);
if (!FINAL_STATUSES.has(transaction.status)) {
timeoutId = window.setTimeout(loadStatus, 2000);
}
} catch (requestError) {
if (requestError.name === "AbortError" || stopped) {
return;
}
setError("暂时无法获取支付状态,正在重试");
timeoutId = window.setTimeout(loadStatus, 5000);
}
}
loadStatus();
return () => {
stopped = true;
controller.abort();
window.clearTimeout(timeoutId);
};
}, [transactionId]);
return (
<section>
<p>当前支付状态:{status}</p>
{error && <p role="alert">{error}</p>}
</section>
);
}
这里使用递归 setTimeout(),可以避免前一次请求还没有结束,下一次请求就已经发出。页面卸载时,组件会通过 AbortController 取消正在进行的查询。
生产环境还应设置最长等待时间。例如,轮询两分钟后停止自动查询,并提示用户稍后到订单页面查看,避免无限发送请求。
支付 ID 应如何传给状态页面
一种常见做法是先由后端创建本地支付记录:
POST /api/payment-transactions
后端返回本地交易 ID,以及支付平台所需的信息:
{
"transactionId": "pay_123",
"checkoutUrl": "https://payment-provider.example/checkout/..."
}
支付完成后,React 跳转到:
/payments/pay_123/status
状态页面使用 pay_123 查询自己的后端。与此同时,支付平台通过 webhook 把最终状态发送给后端。
不能完全信任支付完成后的前端跳转参数。用户进入了“支付成功”页面,不等于款项已经支付成功。最终状态应以验证过的 webhook 为准。如果支付服务商明确提供服务端交易查询接口,也可以在异常情况下用它进行补充核验。
如果需要即时推送
轮询足以应对多数支付结果页。如果状态必须立即更新,后端处理完 webhook 后,可以通过 SSE 或 WebSocket 通知浏览器:
Webhook -> Backend -> Database
-> SSE/WebSocket -> React
即使使用实时推送,仍应以数据库中的状态为准。Webhook 到达时,浏览器可能正处于断线状态,因此 React 页面首次加载或重新连接后,仍需主动查询一次当前状态,不能只依赖推送消息。
SSE 适合后端向浏览器单向推送状态。WebSocket 更适合需要双向实时通信的场景。如果只是显示一次支付结果,引入 WebSocket 通常会增加额外的连接管理成本。
需要特别注意的问题
Webhook 可能早于页面查询,也可能晚于页面查询
React 第一次查询时返回 pending 很正常。支付平台的页面跳转和 webhook 通知是两个独立流程,先后顺序无法保证。
Webhook 可能重复发送
后端应使用 webhook 的事件 ID 做幂等处理。同一个事件重复到达时,不能重复记账、发货或发送通知。
状态事件可能乱序
如果支付平台会重试旧事件,后端应结合事件时间、版本号或服务商提供的顺序字段,防止旧状态覆盖新状态。
Webhook 应快速响应
不要在 webhook 请求中执行耗时操作。通常应先完成验证和必要的持久化,然后返回成功响应。发送邮件、发货等后续工作可以交给任务队列处理。具体的超时和重试规则由支付服务商决定。
不要把密钥放进 React
Webhook secret、支付 API secret 和服务端访问令牌只能保存在后端。React 构建产物中的环境变量最终会发送到浏览器,不能用来保存真正的秘密。
不要根据前端状态执行关键业务
React 中显示的状态只用于界面反馈。发货、开通服务、增加余额等关键操作,必须由后端根据验证过的支付结果执行。
更稳妥的实现方式是让 webhook 写入状态,由查询接口读取状态,React 只负责展示。这样即使用户刷新页面、暂时断网或错过实时通知,也能重新查询这笔支付的真实结果。
备注:内容仅供参考。
