支持 INR 并可用于 nodejs 应用的 Payment gateway
结论
Node.js 应用接收 INR 付款时,可以考虑以下支付网关:
- Razorpay:Node.js 接入文档较完整,支持 INR、Hosted Checkout、银行卡、UPI、Netbanking 等,适合大多数印度本地业务。
- Cashfree Payments:支持 INR 和多种印度本地支付方式,并提供服务端 API 和托管收银台。
- PayU India:主要面向印度市场,接入流程和商户审核可能更偏向企业客户。
- CCAvenue:覆盖多种支付方式,通常会跳转到托管支付页面完成付款。
- Stripe India:技术接口成熟,但印度商户的开户条件、收款能力及跨境交易限制需要根据当前政策确认。
如果没有特殊的银行渠道或企业合同要求,可以优先评估 Razorpay 和 Cashfree Payments。两者都适合采用这样的接入方式:Node.js 服务端创建订单,浏览器打开托管收银台,服务端验证回调。
支付产品、开户条件和监管要求可能发生变化。正式选型前,应以网关当前的官方文档和商户审核结果为准。
推荐的接入流程
支付订单不能直接由浏览器创建,API Secret 也不能放进前端代码。建议采用以下流程:
- 用户在应用中提交订单。
- Node.js 服务端根据数据库中的商品价格计算金额。
- 服务端调用支付网关 API 创建支付订单。
- 前端使用返回的订单 ID 打开支付网关的 Hosted Checkout。
- 支付完成后,前端将支付结果提交给服务端。
- 服务端验证签名,并通过 Webhook 或支付查询接口确认最终状态。
- 验证成功后,再将业务订单标记为已支付。
支付是否成功必须由服务端确认,不能直接采用前端传回的状态。
Razorpay 的 Node.js 示例
下面使用 Razorpay Orders API 展示基本的接入结构。生产环境还需要加入身份认证、数据库事务、幂等控制和 Webhook 处理。
安装依赖:
npm install express razorpay dotenv
环境变量:
RAZORPAY_KEY_ID=your_key_id
RAZORPAY_KEY_SECRET=your_key_secret
创建订单并验证支付签名:
import "dotenv/config";
import crypto from "node:crypto";
import express from "express";
import Razorpay from "razorpay";
const app = express();
app.use(express.json());
const razorpay = new Razorpay({
key_id: process.env.RAZORPAY_KEY_ID,
key_secret: process.env.RAZORPAY_KEY_SECRET,
});
app.post("/api/payments/order", async (req, res) => {
try {
// 生产环境应根据 productId 或业务订单从数据库读取价格,
// 不要直接采用客户端提交的金额。
const amountInRupees = 499;
const amountInPaise = amountInRupees * 100;
const order = await razorpay.orders.create({
amount: amountInPaise,
currency: "INR",
receipt: `receipt_${Date.now()}`,
});
// 此处应保存 order.id、业务订单号、金额和初始状态。
res.json({
keyId: process.env.RAZORPAY_KEY_ID,
orderId: order.id,
amount: order.amount,
currency: order.currency,
});
} catch (error) {
console.error("Failed to create payment order:", error);
res.status(500).json({
message: "Unable to create payment order",
});
}
});
app.post("/api/payments/verify", (req, res) => {
const {
razorpay_order_id,
razorpay_payment_id,
razorpay_signature,
} = req.body;
if (
!razorpay_order_id ||
!razorpay_payment_id ||
!razorpay_signature
) {
return res.status(400).json({
verified: false,
message: "Missing payment fields",
});
}
const payload = `${razorpay_order_id}|${razorpay_payment_id}`;
const expectedSignature = crypto
.createHmac("sha256", process.env.RAZORPAY_KEY_SECRET)
.update(payload)
.digest("hex");
const received = Buffer.from(razorpay_signature, "utf8");
const expected = Buffer.from(expectedSignature, "utf8");
const verified =
received.length === expected.length &&
crypto.timingSafeEqual(received, expected);
if (!verified) {
return res.status(400).json({
verified: false,
message: "Invalid payment signature",
});
}
// 此处还应核对数据库中的 orderId、金额和币种,
// 并等待 Webhook 或调用查询 API 确认最终支付状态。
return res.json({ verified: true });
});
app.listen(3000, () => {
console.log("Server listening on http://localhost:3000");
});
金额通常需要使用最小货币单位。对于 INR,₹499.00 应以 49900 paise 提交。处理金额时应避免使用浮点数。
前端打开托管收银台
页面加载 Razorpay Checkout 脚本后,先向自己的服务端请求创建订单,再打开支付窗口:
<script src="https://checkout.razorpay.com/v1/checkout.js"></script>
<button id="pay-button">Pay ₹499</button>
<script>
document
.getElementById("pay-button")
.addEventListener("click", async () => {
const orderResponse = await fetch("/api/payments/order", {
method: "POST",
headers: {
"Content-Type": "application/json"
}
});
if (!orderResponse.ok) {
throw new Error("Could not create payment order");
}
const order = await orderResponse.json();
const checkout = new Razorpay({
key: order.keyId,
order_id: order.orderId,
amount: order.amount,
currency: order.currency,
name: "Example Store",
description: "Order payment",
handler: async function (payment) {
const verifyResponse = await fetch("/api/payments/verify", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payment)
});
const result = await verifyResponse.json();
if (result.verified) {
window.location.href = "/payment/success";
} else {
window.location.href = "/payment/failed";
}
}
});
checkout.open();
});
</script>
浏览器端的 handler 被调用,只能说明前端收到了支付结果,不能将其作为发货或开通服务的依据。
Webhook 也要验证
用户付款后可能立即关闭页面,导致前端没有调用 /api/payments/verify。因此,生产环境还需要配置 Webhook。
验证 Webhook 时通常需要:
- 保留未经 JSON 解析的原始请求体。
- 使用单独配置的 Webhook Secret 计算 HMAC。
- 验证请求头中的签名。
- 核对事件中的订单号、金额、币种和支付状态。
- 使用事件 ID 或支付 ID 进行幂等处理。
- 在数据库事务中更新业务订单。
Razorpay 的支付结果签名使用 API Secret,Webhook 通常使用单独设置的 Webhook Secret,不能混用。具体的事件名称、请求头和验签格式应以当前官方文档为准。
如何在几个网关之间选择
选型时不能只看网关是否写着“支持 INR”,还需要确认:
- 是否接受你的公司注册国家和经营主体类型。
- 是否支持印度境内交易、国际卡或跨境 INR 交易。
- 是否提供业务所需的 UPI、Netbanking、银行卡和钱包等渠道。
- 结算周期、退款费用、拒付处理方式和最低交易费用。
- 是否支持订阅、自动扣款、部分退款和分账。
- 是否提供 Hosted Checkout,以免自行处理银行卡敏感信息。
- Webhook 是否可靠,能否查询支付的最终状态。
- 测试环境与正式环境有哪些行为差异。
- 商户审核需要提交哪些 KYC、银行账户、税务及公司材料。
如果业务主体不在印度,即使某个网关支持 INR,也不代表该主体可以用 INR 收款,或能向印度客户提供全部本地支付方式。开发前必须向网关确认这一点。
常见问题
不要把 Secret 放进前端
key_id 一般可以用于初始化 Checkout,key_secret 必须只保存在服务端。不要将它写入浏览器代码、移动端包、公开仓库或日志。
不要相信客户端金额
客户端请求可能被篡改。服务端应根据商品 ID、优惠规则和数据库订单重新计算应付金额,并在支付完成后再次核对。
使用幂等处理
Webhook 可能重复发送,客户端也可能重复提交。数据库中应对支付 ID、网关订单 ID 或业务订单号设置唯一约束,防止重复发货或重复开通服务。
区分业务订单和支付订单
建议分别保存:
business_order_id
gateway_order_id
gateway_payment_id
amount
currency
payment_status
created_at
paid_at
业务订单状态也不应只用一个布尔值表示。可以设置为 pending、paid、failed、refunded 等明确状态。
不要仅依赖页面跳转
成功页面、失败页面和浏览器回调都可能被伪造或中断。最终状态应来自验签通过的 Webhook,或由服务端调用支付查询 API 获取。
实际建议
可以先在 Razorpay 或 Cashfree 的测试环境中跑通以下最小流程:
- 服务端创建 INR 订单。
- 前端打开 Hosted Checkout。
- 服务端验证支付签名。
- Webhook 更新数据库状态。
- 测试成功、失败、取消、重复通知和退款场景。
确认商户主体能够通过审核、所需支付方式可以开通,并且结算条款可以接受后,再确定最终使用的网关。这样能避免技术接入已经完成,却因为开户或合规条件无法上线。
备注:内容仅供参考。
