如何创建自定义 UPI payment api 并验证交易?
结论
可以自建 UPI 收款与交易验证 API,但这套 API 只能封装业务系统与收单银行之间的交互。一般商户无法像调用普通开放 API 一样直接连接 NPCI,也不能只凭 UPI ID、UTR 或付款截图向 NPCI 验证交易。
如果完全不使用第三方支付网关,可行的做法是:
- 与支持 UPI 商户收单的银行签约;
- 完成企业、网站/App、结算账户和合规审核;
- 获取银行分配的 merchant ID、VPA、密钥、证书和 API 权限;
- 通过银行 API 创建付款请求或动态 QR;
- 使用银行的异步通知和交易状态查询接口验证付款;
- 在自己的服务器上处理订单、验签、对账和退款。
换句话说,可以不使用第三方支付网关,但通常不能绕过银行或其他获准的 UPI 参与机构,直接调用 NPCI 的公共 API 收款。
为什么不能直接接入 NPCI
NPCI 负责运营 UPI 网络,但 UPI 不是向任意开发者开放的普通互联网支付 API。生产环境通常涉及以下角色:
- issuer bank:付款人的发卡银行;
- acquiring/beneficiary bank:为商户提供收款服务的银行;
- PSP bank:提供 UPI 接入能力的银行;
- TPAP:依托 PSP bank 提供 UPI App 服务的第三方应用;
- merchant:通过收单银行或支付服务商接收付款。
直接成为 UPI 网络参与方,需要满足准入、合规、安全、审计、争议处理和技术认证等要求。这与注册开发者账号后获取 API key 完全不同。
NPCI 发布规范或开发资料,也不代表它向普通商户提供了可直接用于生产收款的公共 API。具体准入条件可能发生变化,应以 NPCI 和合作银行当时提供的正式材料为准。
推荐的系统结构
自建 API 可以采用下面的结构:
用户的 UPI App
│
▼
UPI / 银行网络
│
▼
商户收单银行
│
├── Payment API
├── Status API
├── Refund API
└── Webhook / Callback
│
▼
自建 UPI Payment API
│
├── 订单数据库
├── 验签与幂等处理
├── 状态查询
├── 对账任务
└── 业务系统
这层 API 可以统一不同银行的字段和状态,但资金处理和权威交易状态仍由签约银行提供,不能由本地代码自行判断。
实现步骤
1. 向银行申请 UPI 商户收单
联系银行的 corporate banking、merchant acquiring 或 API banking 团队,确认银行是否提供以下能力:
- UPI merchant onboarding;
- merchant VPA;
- dynamic QR 或 intent payment;
- payment status enquiry;
- server-to-server callback;
- refund;
- settlement report;
- sandbox/UAT 环境;
- 签名或双向 TLS 认证。
在银行开放平台看到 API 文档,并不表示注册后就能自动获得生产权限。许多接口需要签署协议、通过审核,再由银行人工开通。
还要提前确认这些接口是否面向普通企业商户,而不是仅供银行、PSP、聚合支付机构或特定合作伙伴使用。
2. 为每次付款创建唯一订单
同一个金额加同一个固定二维码,无法可靠地识别订单。每次付款至少应生成:
merchant_order_id:商户侧唯一订单号;amount:固定金额;currency:通常为INR;customer_reference:可选的业务标识;expires_at:付款请求有效期;- 银行返回的
bank_reference或同类标识。
数据库可以保存以下状态:
CREATED
PENDING
SUCCESS
FAILED
EXPIRED
REFUNDED
订单号必须唯一。金额应使用定点小数或最小货币单位保存,不要用浮点数直接比较金额。
3. 调用银行的付款 API
各家银行的 URL、字段和签名规则并不统一。下面展示的只是自建服务结构,并非 NPCI 或任何一家银行的真实接口:
async function createUpiPayment(order) {
const request = {
merchantId: process.env.BANK_MERCHANT_ID,
merchantOrderId: order.id,
amount: order.amount,
currency: "INR",
callbackUrl: "https://merchant.example.com/webhooks/upi"
};
const signature = signBankRequest(request);
const response = await fetch(`${process.env.BANK_API_BASE_URL}/payments`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Signature": signature
},
body: JSON.stringify(request)
});
if (!response.ok) {
throw new Error(`Bank API returned HTTP ${response.status}`);
}
return response.json();
}
signBankRequest() 必须严格按照银行文档实现。银行可能要求 HMAC、RSA 签名、JWS、客户端证书或双向 TLS,商户不能自行选择算法。
付款接口一般会返回动态 QR 内容、支付链接、intent URI 或银行侧交易标识,具体形式由合作银行决定。
4. 接收并验证 webhook
收到 webhook 后,不能仅看到 status: SUCCESS 就发货。至少要完成以下检查:
- 按银行规定的方法验证签名;
- 检查通知中的商户身份;
- 查找本地订单;
- 核对订单号、金额和币种;
- 防止重复处理通知;
- 必要时再次调用银行的状态查询接口;
- 使用数据库事务更新订单。
下面仍是通用示例,字段和请求头需要按银行的实际规范替换:
app.post(
"/webhooks/upi",
express.raw({ type: "application/json" }),
async (req, res) => {
const signature = req.get("X-Signature");
const rawBody = req.body;
if (!verifyBankSignature(rawBody, signature)) {
return res.status(401).send("invalid signature");
}
const event = JSON.parse(rawBody.toString("utf8"));
const order = await db.orders.findById(event.merchantOrderId);
if (!order) {
return res.status(404).send("unknown order");
}
if (
event.currency !== "INR" ||
event.amount !== order.amount ||
event.merchantId !== process.env.BANK_MERCHANT_ID
) {
return res.status(400).send("payment mismatch");
}
if (order.status === "SUCCESS") {
return res.status(200).send("ok");
}
const bankStatus = await queryBankPaymentStatus(
event.merchantOrderId,
event.bankReference
);
if (
bankStatus.status === "SUCCESS" &&
bankStatus.amount === order.amount &&
bankStatus.currency === "INR"
) {
await db.transaction(async (tx) => {
await tx.orders.markPaid(order.id, {
bankReference: bankStatus.bankReference,
paidAt: bankStatus.paidAt
});
await tx.outbox.insert({
type: "ORDER_PAID",
aggregateId: order.id
});
});
}
return res.status(200).send("ok");
}
);
如果银行要求对原始 HTTP body 验签,就要在解析 JSON 前保留原始字节。重新序列化 JSON 可能改变空格、字段顺序或编码,导致验签失败。
5. 提供主动状态查询
Webhook 可能延迟、重复,或暂时无法送达,因此还需要实现主动状态查询:
app.get("/api/payments/:orderId", async (req, res) => {
const order = await db.orders.findById(req.params.orderId);
if (!order) {
return res.status(404).json({ error: "ORDER_NOT_FOUND" });
}
if (order.status === "PENDING") {
const result = await queryBankPaymentStatus(
order.id,
order.bankReference
);
await reconcileOrderWithBank(order, result);
}
const updatedOrder = await db.orders.findById(req.params.orderId);
res.json({
orderId: updatedOrder.id,
status: updatedOrder.status,
amount: updatedOrder.amount,
currency: updatedOrder.currency
});
});
前端只能查询自己的后端,不能让浏览器直接调用银行接口。密钥、证书和签名逻辑必须留在服务端。
什么才算付款验证成功
可信的成功状态应来自签约银行提供的 webhook、状态查询接口或结算文件,并且同时满足以下条件:
- 商户订单号一致;
- merchant ID 或 merchant VPA 一致;
- 金额与币种一致;
- 银行侧状态为最终成功;
- 银行交易标识未被其他订单使用;
- 通知签名有效;
- 订单此前没有被支付或关闭。
以下信息不能单独作为付款成功的依据:
- 用户上传的截图;
- 用户填写的 UTR、RRN 或 transaction ID;
- 前端跳转到了”支付完成”页面;
- UPI App 显示了成功画面;
- 客户端上报的成功状态;
- 静态二维码被扫描;
- 银行短信或人工提交的通知。
尤其不能只凭用户提供的 UTR 自动发货,因为该标识可能填写错误、被重复提交,甚至属于另一笔交易。能否通过 UTR 查询交易,还要看银行是否向该商户开放了相应接口。
UPI URI 不能代替交易验证
生成下面这样的 UPI URI,并不表示已经拥有 UPI payment API:
upi://pay?pa=merchant@bank&pn=Merchant&am=499.00&cu=INR&tr=ORDER123
它只能打开兼容的 UPI App,并预先填写付款信息。应用跳转回来时附带的客户端结果不能作为最终付款凭证,服务端仍需向收单银行确认交易状态。
URI 参数、商户标识、签名要求和可用的支付流程,也可能受到银行及 UPI 规则限制。正式上线前,应使用银行认可的生成方式,不能只靠自行拼接 URI。
必须处理的工程问题
幂等性
银行可能多次发送同一条通知。应为 merchant_order_id 和银行交易标识建立唯一约束,避免重复通知造成重复发货、充值或记账。
状态不能随意回退
订单一旦变为 SUCCESS,之后即使收到延迟的 PENDING 通知,也不应将其改回待支付。服务端需要明确规定状态转换规则。
超时不代表失败
调用银行 API 超时,只能说明结果未知。此时应让订单继续保持 PENDING,再通过状态查询或对账任务确认结果,不能立即用同一个订单号创建另一笔支付。
对账
至少每天获取一次银行的交易或结算报告,并与本地订单逐笔核对。Webhook 返回成功,不一定表示资金已经结算。交易成功、退款成功和资金结算应分别管理状态。
安全
至少应落实以下措施:
- TLS;
- 银行要求的 IP allowlist;
- 双向 TLS 或请求签名;
- 密钥轮换;
- webhook 防重放;
- 原始请求审计;
- 敏感数据脱敏;
- 最小权限控制;
- 服务端时间同步;
- 告警与人工补单流程。
不要把银行密钥、merchant secret 或私钥放进网页、移动 App 或公开代码仓库。
是否值得完全自建
对于单个商户和一家合作银行,自建一层 UPI API 是可行的。这样可以减少对第三方网关的依赖,订单和对账逻辑也能由自己控制。
但自建并不等于绕过支付参与方。商户仍需负责:
- 与银行进行商务签约和技术联调;
- 通过 UAT 与生产审核;
- 管理证书和密钥;
- 保障 API 可用性与容灾;
- 处理 webhook 补偿;
- 处理对账、退款和争议;
- 维护安全与合规。
较稳妥的做法是先选择一家提供完整 UPI merchant acquiring API 的银行,索取正式的 onboarding、payment、status、callback、refund 和 reconciliation 文档,再围绕这些接口开发统一 API。如果银行只提供账户查询或企业转账 API,没有 UPI 商户收单能力,这些 API 就不能替代支付网关。
备注:内容仅供参考。
