PAYATHON 2026

Square Up API 退款一直处于 "Pending" 状态

支付小周

结论

退款返回 PENDING 通常不表示 API 调用失败。它说明 Square 已接受退款请求,但支付网络或银行仍在处理。要获取最终状态,可以使用以下两种方式:

  1. 订阅退款相关 webhook,在状态变化后更新本地记录。
  2. 根据 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 请求。

备注:内容仅供参考。