PAYATHON 2026

Square API 集成 Cash App 时 ontokenization 提前触发

支付阿杰

结论

ontokenization 表示 Cash App Pay 已返回令牌化结果,但付款并未因此完成。只有服务端调用 Square Payments API 并收到成功结果后,才能将订单标记为已支付。

如果用户尚未在 Cash App 中授权,却收到了 tokenResult.status === 'OK',不能将其视为可靠的“用户点击了 OK”信号。网页代码也无法直接监听 Cash App 内部的 OK 按钮。建议这样处理:

  • 只有在 tokenResult.status === 'OK' 时,才请求服务端创建付款。
  • 服务端使用幂等键,避免返回、重定向或重复事件造成重复扣款。
  • 不要在事件处理函数开头无条件调用 paymentFail(detail)。
  • 使用固定且可验证的 redirectURL,不要直接使用可能发生变化的 window.location.href。
  • 尽量在 Square 支持的系统浏览器中完成 Cash App Pay 流程。嵌入式 WebView 在应用切换和页面恢复时可能产生异常或重复回调,需要按照当前 Square Web Payments SDK 的支持范围进行验证。

为什么返回 token 不代表付款完成

Cash App Pay 流程分为两个阶段:

  1. Web Payments SDK 获取 payment token。
  2. 服务端将 token 作为 source_id 调用 CreatePayment。

ontokenization 只对应第一个阶段。即使返回:

tokenResult.status === 'OK'

也只能说明 SDK 返回了一个可用于后续请求的 token。服务端付款仍可能失败,原因包括:

  • token 已使用或失效;
  • 金额、币种或 location 不匹配;
  • 风控或账户状态导致付款被拒绝;
  • 网络重试产生重复请求;
  • CreatePayment 返回非成功状态。

因此,拿到 token 后不能直接发放 bucks。订单状态必须以服务端创建付款后的最终结果为准,必要时还应查询 Payment 或通过 webhook 再次确认。

当前代码中的直接问题

ontokenization 回调开头有这样一行代码:

await paymentFail(detail);

这行代码会在每次回调时执行,包括 status === 'OK' 的情况。也就是说,前端每次收到令牌化事件,都会先调用失败接口,然后才判断是否创建付款。

应将这行代码移到失败分支中。

应用切换、页面恢复和重定向也可能导致处理函数重复执行。仅在浏览器端设置布尔变量并不能彻底解决问题,因为页面重新加载后,变量会丢失。重复付款必须由服务端通过幂等机制来防止。

建议的前端处理方式

let tokenizationProcessing = false;

cashAppPay.addEventListener('ontokenization', async ({ detail }) => {
  const tokenResult = detail?.tokenResult;

  if (!tokenResult) {
    redirectToPaymentResults(
      'FAILURE',
      'Cash App Pay did not return a tokenization result.'
    );
    return;
  }

  if (tokenResult.status !== 'OK' || !tokenResult.token) {
    await paymentFail(detail);

    let errorMessage =
      `Tokenization failed with status: ${tokenResult.status}`;

    if (tokenResult.errors) {
      errorMessage +=
        ` and errors: ${JSON.stringify(tokenResult.errors)}`;
    }

    redirectToPaymentResults('FAILURE', errorMessage);
    return;
  }

  // 防止当前页面生命周期内重复处理。
  if (tokenizationProcessing) {
    return;
  }

  tokenizationProcessing = true;

  try {
    const paymentResults = await createPayment(
      tokenResult.token,
      tokenResult
    );

    if (paymentResults.status === 'SUCCESS') {
      redirectToPaymentResults('SUCCESS', '');
      return;
    }

    redirectToPaymentResults(
      'FAILURE',
      paymentResults.message || 'Payment was not completed.'
    );
  } catch (error) {
    tokenizationProcessing = false;

    redirectToPaymentResults(
      'FAILURE',
      error instanceof Error ? error.message : 'Payment request failed.'
    );
  }
});

redirectURL 建议使用固定地址:

const cashAppPay = await payments.cashAppPay(paymentRequest, {
  redirectURL: 'https://example.com/web/payment/cash-app-return',
  referenceId: 'order-123'
});

不要在所有交易中都使用这个固定值:

referenceId: 'my-website-00000001'

referenceId 应与当前订单关联,但不能作为防止重复付款的唯一机制。

服务端必须进行幂等处理

前端应提交稳定的订单号,不要依赖临时的浏览器状态:

async function createPayment(token) {
  const body = JSON.stringify({
    locationId,
    sourceId: token,
    orderId: '123'
  });

  const response = await fetch('/web/payment/cash-app-payment', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body
  });

  if (!response.ok) {
    throw new Error(await response.text());
  }

  return response.json();
}

服务端需要为同一订单保存一个稳定的 Square idempotency key。不要在每次重试时重新生成,否则 Square 会把重试当作新的付款。

伪代码如下:

$orderId = (string) $requestData['orderId'];
$sourceId = (string) $requestData['sourceId'];

// 从数据库读取;首次处理订单时生成并永久保存。
$idempotencyKey = getOrCreatePaymentIdempotencyKey($orderId);

// 金额和币种必须从服务端订单中读取,不能相信前端传值。
$order = loadPendingOrder($orderId);

if ($order->isPaid()) {
    return existingPaymentResponse($order);
}

$result = createSquarePayment([
    'source_id'       => $sourceId,
    'idempotency_key' => $idempotencyKey,
    'location_id'     => $order->locationId,
    'amount_money'    => [
        'amount'   => $order->amountInMinorUnits,
        'currency' => $order->currency,
    ],
    'reference_id'    => $orderId,
]);

if (paymentIsCompleted($result)) {
    markOrderPaidOnce($orderId, $result);
}

具体的 PHP 类名和调用方式取决于所使用的 Square PHP SDK 版本,应以该版本的 SDK 接口为准。

能否等待 Cash App 的 OK 按钮

Web Payments SDK 无法单独监听 Cash App 应用内部的 OK 按钮。对网页集成来说,成功的 tokenization 结果就是 SDK 提供的授权结果。开发者不应根据页面切换时间、返回按钮或 WebView 生命周期来推断用户是否点击了 OK。

如果业务还需要额外确认,可以在收到 token 后显示一个站内确认按钮,由用户确认后再调用服务端。这只是商户侧的二次确认,并不等于监听 Cash App 内部的按钮,而且 token 可能存在使用时限。

也可以由服务端创建延迟完成的付款,等业务条件满足后再完成付款,例如使用 CreatePayment 支持的延迟完成机制。该机制是否适用于 Cash App Pay、付款可保留多久以及会进入哪些状态,应以当前 Square Payments API 文档和账户能力为准。

WebView 场景需要重点排查

如果问题只发生在 mobile app 的 WebView 中,应使用同一账号和订单分别测试以下场景:

  • iOS Safari 或 Android Chrome;
  • 应用内 WebView;
  • 用户确认;
  • 用户取消或按返回键。

如果系统浏览器正常,而 WebView 出现异常,问题通常与应用切换、重定向恢复或 WebView 的支持范围有关,不应通过修改 ontokenization 的判断来绕过。

如果在 Square 当前明确支持的浏览器中,用户未授权时仍能稳定收到 status: 'OK',应停止根据该结果自动发货,并向 Square 支持团队提供以下信息:

  • Square.js/Web Payments SDK 的加载地址及测试时间;
  • 操作系统、浏览器或 WebView 版本;
  • application ID、location ID 和测试环境类型;
  • referenceId、订单号及 Square request ID;
  • 完整但已脱敏的 tokenResult;
  • 用户点击、跳转、返回和事件触发的时间顺序。

不要记录或提交完整的 payment token。要避免误处理和重复扣款,最终仍需依靠服务端付款状态、订单幂等控制和一次性发货约束。

备注:内容仅供参考。