PAYATHON 2026

移动应用如何支持通过 BHIM 和 UPI 支付?

支付老李

结论

移动应用可以提供“使用 BHIM 支付”入口,但 BHIM 本身是一款支持 UPI 的支付应用。大多数情况下,无须也不应依赖单独的 BHIM SDK。

常见的接入方式有两种:

  1. 使用支持 UPI Intent、UPI QR 或 UPI App Invoke 的支付网关或收单机构 SDK。网关负责拉起 BHIM 等 UPI 应用,并提供服务端支付结果查询和 webhook。
  2. 构造标准 UPI deep link,例如 upi://pay?...,由系统拉起用户已安装的 BHIM 或其他 UPI 应用。应用返回的结果只能用于界面提示,最终支付状态仍须由服务端向支付网关、收单机构或 PSP 查询确认。

不能只凭 BHIM 返回给移动应用的 SUCCESS 字符串发货。客户端返回值可能被伪造,也可能遇到用户已经扣款但应用没有收到回调、返回 PENDING 或网络中断等情况。

BHIM 和 UPI 的关系

BHIM 是 UPI 生态中的付款应用,UPI 才是底层使用的支付基础设施。因此,商户一般接入 UPI 支付能力,而不是专门为 BHIM 开发一套支付流程。

用户点击“UPI 支付”后,系统可以列出设备上已安装的 UPI 应用,例如 BHIM、Google Pay、PhonePe 或银行应用。只有业务明确需要突出 BHIM 时,才适合显示“使用 BHIM 支付”,同时还要准备未安装 BHIM 时的备用方案。

信用卡、借记卡、网银和 UPI 也无须分别自行对接。通常可以选择同时支持这些支付方式的支付网关,并确认其移动 SDK 当前是否支持:

  • Android UPI Intent
  • iOS UPI App Invoke
  • UPI QR
  • 服务端订单查询
  • 异步 webhook
  • 退款和对账
  • PENDING 状态处理

实际可用能力取决于支付网关、收单银行以及商户账户的开通情况,不能只看网页上列出的支付方式。

推荐的支付流程

可以按以下流程实现:

  1. 移动应用向商户服务端提交订单。
  2. 服务端生成唯一订单号,并根据服务端保存的商品价格计算应付金额。
  3. 服务端调用支付网关创建支付订单,获取网关订单号或支付参数。
  4. 移动应用通过网关 SDK 或 UPI Intent 拉起 BHIM 等 UPI 应用。
  5. 用户在 UPI 应用中确认付款。
  6. UPI 应用将结果返回移动应用,移动端显示“正在确认支付”。
  7. 支付网关向商户服务端发送 webhook,或者由服务端主动查询订单状态。
  8. 服务端验证订单号、金额、币种、商户号和签名,将验证通过的订单更新为已支付。
  9. 移动应用从商户服务端获取最终状态,再显示支付成功或失败。

支付状态最好至少包括:

CREATED
PAYMENT_PENDING
PAID
FAILED
EXPIRED
REFUNDED

收到 PENDING 或暂时查不到结果时,不要立即将订单判定为失败。应保留订单,并在延迟后进行有限次数的查询。

标准 UPI 支付 URI 通常采用以下形式:

upi://pay?pa=merchant@bank&pn=Example%20Store&tr=ORDER12345&tn=Order%2012345&am=499.00&cu=INR

常见参数的含义如下:

  • pa:收款方 VPA。
  • pn:收款方名称。
  • tr:交易参考号,应保持唯一,并与服务端订单关联。
  • tn:交易说明。
  • am:金额。
  • cu:币种,UPI 场景通常使用 INR。

所有参数都必须经过 URL 编码。收款 VPA、商户身份和交易参数应由服务端生成或校验,不能直接相信客户端提交的金额。

Android 示例

Android 可以使用 ACTION_VIEW 拉起能够处理 UPI URI 的应用。下面的代码只演示基本调用方式,并不是完整的生产支付实现:

private val upiLauncher =
    registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
        val response = result.data?.getStringExtra("response")
            ?: result.data?.dataString

        // 这里只用于更新界面,不能据此发货。
        showPaymentPending()

        // 将订单号提交给自己的服务端,查询最终支付状态。
        refreshOrderStatus(orderId)
    }

fun startUpiPayment(
    merchantVpa: String,
    merchantName: String,
    orderId: String,
    amount: String
) {
    val uri = Uri.Builder()
        .scheme("upi")
        .authority("pay")
        .appendQueryParameter("pa", merchantVpa)
        .appendQueryParameter("pn", merchantName)
        .appendQueryParameter("tr", orderId)
        .appendQueryParameter("tn", "Payment for $orderId")
        .appendQueryParameter("am", amount)
        .appendQueryParameter("cu", "INR")
        .build()

    val intent = Intent(Intent.ACTION_VIEW, uri)

    if (intent.resolveActivity(packageManager) != null) {
        upiLauncher.launch(Intent.createChooser(intent, "Pay with UPI"))
    } else {
        showError("未找到支持 UPI 的支付应用")
    }
}

不同 UPI 应用返回的数据格式可能有所不同。有些应用返回 response,有些则可能返回空值。即使解析出下面这样的内容,也只能将其视为客户端结果:

Status=SUCCESS&txnRef=ORDER12345&ApprovalRefNo=123456789012

客户端可以采用宽松的解析方式,但不能把解析结果当作订单已经支付的唯一证据:

fun parseUpiResponse(response: String?): Map<String, String> {
    if (response.isNullOrBlank()) return emptyMap()

    return response
        .split("&")
        .mapNotNull { part ->
            val index = part.indexOf("=")
            if (index <= 0) {
                null
            } else {
                val key = part.substring(0, index).trim().lowercase()
                val value = part.substring(index + 1).trim()
                key to value
            }
        }
        .toMap()
}

不要绑定未经确认的 BHIM package name。包名、调用方式和分发渠道都可能变化,而且强制指定某个应用会排除其他 UPI 应用。如果业务确实只允许拉起 BHIM,应以 BHIM 或支付服务商当前发布的官方接入资料为准,并保留通用 UPI 入口作为备用方案。

iPhone 集成

iOS 也可以尝试打开 UPI URI:

func startUpiPayment(urlString: String) {
    guard let url = URL(string: urlString) else {
        return
    }

    UIApplication.shared.open(url, options: [:]) { opened in
        if !opened {
            // 未安装可处理该 URI 的应用,改为网关页面或 UPI QR。
            self.showPaymentFallback()
        }
    }
}

不过,iOS 上第三方应用的 URL Scheme、回跳机制和应用检测能力会受到支付应用及系统配置的影响。直接使用通用 upi://pay,无法保证在所有设备上都能准确选择 BHIM,也无法保证顺利回跳。

生产环境更适合使用支付网关提供的 iOS SDK 或托管支付页。网关通常会处理应用检测、跳转、回跳和兼容性问题,但最终支付状态仍须由服务端确认。

服务端确认支付结果

服务端应提供独立的订单状态接口:

GET /api/orders/ORDER12345/payment-status

移动端收到 UPI 应用回调后,应调用这个接口,不能直接把订单标记为已支付。

Webhook 可以参考以下处理逻辑:

app.post("/payments/webhook", rawBodyMiddleware, async (req, res) => {
  const signature = req.get("X-Payment-Signature");

  if (!verifyGatewaySignature(req.rawBody, signature)) {
    return res.sendStatus(400);
  }

  const event = JSON.parse(req.rawBody.toString("utf8"));

  const order = await findOrder(event.merchantOrderId);
  if (!order) {
    return res.sendStatus(404);
  }

  if (
    event.status === "SUCCESS" &&
    event.amount === order.amount &&
    event.currency === order.currency &&
    event.merchantId === EXPECTED_MERCHANT_ID
  ) {
    await markOrderPaidOnce(order.id, event.gatewayTransactionId);
  }

  res.sendStatus(200);
});

其中,verifyGatewaySignature、请求头名称和事件字段都只是结构示例。签名算法、字段和证书要求必须以所选支付网关的官方文档为准,不能自行假设。

markOrderPaidOnce 必须具备幂等性,因为支付网关可能重复发送 webhook。同一个网关交易号不能关联多个订单,同一个订单也不能因为重复通知而重复发货。

是否需要 BHIM SDK

对于普通电商应用,通常没有必要寻找面向商户的通用 BHIM SDK。可以按以下顺序选择接入方式:

  • 优先使用支付网关或收单机构的移动 SDK。
  • 其次使用由支付服务商提供参数的标准 UPI Intent/App Invoke。
  • 将 UPI QR 或托管支付页面作为备用方案。
  • 不建议只构造 URI,并完全依赖移动端返回的结果。

如果某个服务商声称提供 BHIM SDK,需要先确认它是 BHIM 官方能力、银行或 PSP 提供的 SDK,还是只对 UPI deep link 做了一层封装。还要核实该服务是否支持商户准入、签名验证、服务端查询、退款、对账和合规要求。

重要注意事项

  • 不要在 App 中保存支付网关密钥、签名私钥或管理凭证。
  • 金额、订单号和收款账户必须由服务端生成或验证。
  • 不要因为 Activity 返回 RESULT_OK 就认定付款成功。
  • 用户取消、应用崩溃、网络中断和银行超时,都可能导致客户端拿不到可靠结果。
  • 支付结果应以网关 webhook 或服务端查询为准。
  • 对重复通知、重复点击和重复查询进行幂等处理。
  • 不要记录完整 VPA、手机号、银行参考号等敏感信息,日志中应进行脱敏。
  • 上线前应使用支付服务商提供的测试环境,覆盖成功、失败、取消、超时、重复通知和长时间 PENDING 等场景。
  • UPI 接入规则和支付应用能力可能发生调整。上线时,应查阅所选支付网关、收单机构及相关平台的最新接入文档。

“Pay with BHIM”可以作为界面上的支付入口,底层仍应按 UPI 支付方式实现。可靠的支付确认不能依赖 BHIM 客户端回调,而应由支付网关或 PSP 在服务端完成验签、查询、通知和对账。

备注:内容仅供参考。