移动应用如何支持通过 BHIM 和 UPI 支付?
结论
移动应用可以提供“使用 BHIM 支付”入口,但 BHIM 本身是一款支持 UPI 的支付应用。大多数情况下,无须也不应依赖单独的 BHIM SDK。
常见的接入方式有两种:
- 使用支持 UPI Intent、UPI QR 或 UPI App Invoke 的支付网关或收单机构 SDK。网关负责拉起 BHIM 等 UPI 应用,并提供服务端支付结果查询和 webhook。
- 构造标准 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状态处理
实际可用能力取决于支付网关、收单银行以及商户账户的开通情况,不能只看网页上列出的支付方式。
推荐的支付流程
可以按以下流程实现:
- 移动应用向商户服务端提交订单。
- 服务端生成唯一订单号,并根据服务端保存的商品价格计算应付金额。
- 服务端调用支付网关创建支付订单,获取网关订单号或支付参数。
- 移动应用通过网关 SDK 或 UPI Intent 拉起 BHIM 等 UPI 应用。
- 用户在 UPI 应用中确认付款。
- UPI 应用将结果返回移动应用,移动端显示“正在确认支付”。
- 支付网关向商户服务端发送 webhook,或者由服务端主动查询订单状态。
- 服务端验证订单号、金额、币种、商户号和签名,将验证通过的订单更新为已支付。
- 移动应用从商户服务端获取最终状态,再显示支付成功或失败。
支付状态最好至少包括:
CREATED
PAYMENT_PENDING
PAID
FAILED
EXPIRED
REFUNDED
收到 PENDING 或暂时查不到结果时,不要立即将订单判定为失败。应保留订单,并在延迟后进行有限次数的查询。
UPI deep link 示例
标准 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 在服务端完成验签、查询、通知和对账。
备注:内容仅供参考。
