Square Up API 退款一直处于 "Pending" 状态
结论
退款返回 PENDING 通常不表示 API 调用失败。它说明 Square 已接受退款请求,但支付网络或银行仍在处理。要获取最终状态,可以使用以下两种方式:
- 订阅退款相关 webhook,在状态变化后更新本地记录。
- 根据
refund_id定时调用GetPaymentRefund查询状态。
生产环境一般以 webhook 为主,同时使用定时查询作为补偿。webhook 负责及时更新状态,定时任务则处理通知丢失、服务暂时不可用等情况。
为什么退款会长期显示为 PENDING
退款不是同步完成的操作。成功创建退款,只说明 Square 已收到请求,不代表资金已经退回。
退款状态通常包括:
PENDING:退款正在处理。COMPLETED:退款成功完成。REJECTED:退款请求被拒绝。FAILED:退款处理失败。
具体有哪些可用状态,应以当前 Square Payments API 文档和实际响应中的 refund.status 为准。
如果退款长时间停留在 PENDING,可以检查以下内容:
- 当前使用的是 Sandbox 还是生产环境。
- 原付款是否已经完成并且可以退款。
- 退款金额和币种是否与原付款一致。
- Square Dashboard 是否显示了额外的失败原因。
refund对象中的updated_at、status和错误信息是否有变化。
推荐的 webhook 工作流程
可以按下面的流程处理退款:
创建退款
↓
保存 refund_id、payment_id、初始状态 PENDING
↓
Square 处理退款
↓
Square 向 webhook URL 发送退款事件
↓
验证 webhook 签名
↓
根据 refund_id 更新本地退款记录
↓
返回 HTTP 2xx
建议订阅 Square 当前版本提供的退款创建和退款更新事件,常见的事件名称为:
refund.created
refund.updated
Square 可能会随 API 版本调整事件名称和管理界面。配置时,应以 Developer Dashboard 中实际可选的事件为准。
1. 创建退款后保存退款 ID
创建退款时必须使用唯一的 idempotency_key,防止网络重试产生重复退款。
curl https://connect.squareup.com/v2/refunds \
-X POST \
-H 'Square-Version: YOUR_SQUARE_VERSION' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
-H 'Content-Type: application/json' \
-d '{
"idempotency_key": "refund-order-10001-v1",
"payment_id": "PAYMENT_ID",
"amount_money": {
"amount": 1000,
"currency": "USD"
},
"reason": "Customer requested refund"
}'
需要将响应中的退款对象保存到本地,例如:
{
"refund_id": "REFUND_ID",
"payment_id": "PAYMENT_ID",
"status": "PENDING",
"updated_at": "..."
}
不要只保存 payment_id。一笔付款可能有多次部分退款,因此本地记录最好使用 refund_id 作为唯一标识。
2. 配置 webhook URL
在 Square Developer Dashboard 中,为对应应用配置 HTTPS webhook,例如:
https://example.com/webhooks/square
配置完成后,保存以下信息:
- Webhook notification URL
- Webhook signature key
- 已订阅的退款事件
- 当前环境是 Sandbox 还是生产环境
Sandbox 和生产环境的访问令牌、签名密钥及 webhook 配置通常相互独立,不能混用。
3. 验证 webhook 签名
不能只根据请求正文判断通知是否来自 Square。应使用 webhook signature key 验证 x-square-hmacsha256-signature 请求头。
签名计算使用的消息一般是:
notification_url + raw_request_body
使用 webhook signature key 计算 HMAC-SHA256 后,再进行 Base64 编码。
下面是不依赖 Square SDK 的 Node.js 示例:
import crypto from "node:crypto";
import express from "express";
const app = express();
const notificationUrl = "https://example.com/webhooks/square";
const signatureKey = process.env.SQUARE_WEBHOOK_SIGNATURE_KEY;
app.post(
"/webhooks/square",
express.raw({ type: "application/json" }),
async (req, res) => {
const rawBody = req.body.toString("utf8");
const receivedSignature =
req.get("x-square-hmacsha256-signature") ?? "";
const expectedSignature = crypto
.createHmac("sha256", signatureKey)
.update(notificationUrl + rawBody, "utf8")
.digest("base64");
const valid =
receivedSignature.length === expectedSignature.length &&
crypto.timingSafeEqual(
Buffer.from(receivedSignature),
Buffer.from(expectedSignature)
);
if (!valid) {
return res.sendStatus(401);
}
let event;
try {
event = JSON.parse(rawBody);
} catch {
return res.sendStatus(400);
}
try {
if (
event.type === "refund.created" ||
event.type === "refund.updated"
) {
const refund = event.data?.object?.refund;
if (refund?.id) {
await upsertRefund({
eventId: event.event_id,
refundId: refund.id,
paymentId: refund.payment_id,
status: refund.status,
updatedAt: refund.updated_at
});
}
}
return res.sendStatus(200);
} catch (error) {
console.error("Square webhook processing failed", error);
return res.sendStatus(500);
}
}
);
upsertRefund 应当是幂等操作,例如:
async function upsertRefund({
eventId,
refundId,
paymentId,
status,
updatedAt
}) {
// 1. 如果 eventId 已处理,则直接返回。
// 2. 根据 refundId 更新或插入退款记录。
// 3. 仅在传入 updatedAt 不早于本地记录时更新状态。
// 4. 将 eventId 保存到 webhook_events 表,防止重复处理。
}
验证签名时必须使用原始请求体。如果先用 express.json() 解析后再重新序列化,请求体的空格、字段顺序或编码可能发生变化,导致签名验证失败。
此外,签名中的 notification URL 必须与 Square 后台配置的 URL 完全一致,包括:
https协议- 域名
- 路径
- 端口
- 尾部斜杠
- URL 大小写
实际项目也可以优先使用当前版本 Square SDK 提供的 webhook 签名验证函数,减少自行处理加密细节的风险。
不使用 webhook 时如何查询退款
可以根据退款 ID 直接调用 GetPaymentRefund:
GET /v2/refunds/{refund_id}
例如:
curl https://connect.squareup.com/v2/refunds/REFUND_ID \
-H 'Square-Version: YOUR_SQUARE_VERSION' \
-H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
-H 'Content-Type: application/json'
典型的响应结构如下:
{
"refund": {
"id": "REFUND_ID",
"payment_id": "PAYMENT_ID",
"status": "COMPLETED",
"updated_at": "..."
}
}
因此,可以定期查询仍处于 PENDING 的退款:
async function refreshPendingRefund(refundId) {
const response = await fetch(
`https://connect.squareup.com/v2/refunds/${encodeURIComponent(refundId)}`,
{
headers: {
Authorization: `Bearer ${process.env.SQUARE_ACCESS_TOKEN}`,
"Square-Version": process.env.SQUARE_API_VERSION,
"Content-Type": "application/json"
}
}
);
if (!response.ok) {
throw new Error(
`GetPaymentRefund failed: ${response.status} ${await response.text()}`
);
}
const { refund } = await response.json();
await updateRefundStatus(
refund.id,
refund.status,
refund.updated_at
);
return refund;
}
定时任务只需要查询尚未进入终态的退款:
SELECT refund_id
FROM refunds
WHERE status = 'PENDING'
AND next_check_at <= CURRENT_TIMESTAMP;
查询间隔不必一直保持很短,可以采用退避策略,例如:
1 分钟 → 2 分钟 → 5 分钟 → 15 分钟 → 30 分钟
具体间隔要根据业务对实时性的要求、退款处理时间和 Square 当前的 API 限流规则确定,不能假设任何查询频率都不会触发限流。
更稳妥的生产方案
生产环境可以同时采用以下机制:
- 创建退款后,立即保存完整的 Square 退款对象。
- 使用 webhook 接收状态更新。
- 按
event_id对 webhook 去重。 - 按
refund_id幂等更新本地记录。 - 比较
updated_at,防止延迟到达的旧事件覆盖较新的状态。 - webhook 接口应尽快返回
2xx,复杂业务交给队列异步处理。 - 对长期处于
PENDING的记录执行低频补偿查询。 - 对
FAILED或REJECTED状态保留原始响应,方便排查。 - 不要因为 webhook 重试而再次调用创建退款接口。
- 所有退款请求都应使用稳定且唯一的
idempotency_key。
如果暂时无法实现 webhook,可以轮询 GET /v2/refunds/{refund_id}。长期运行时,使用 webhook 接收更新并辅以补偿轮询,通常更可靠,也能减少无效的 API 请求。
备注:内容仅供参考。
