如何为 eSewa Online payment 创建 POST Callback URL
结论
Callback-url 应填写部署在商户服务器上的公网 HTTPS 接口,例如:
https://api.example.com/payments/esewa/callback
Android 中的 <call_back_url> 填写这个地址。它不是 Android Activity 或 Deep Link,也不能指向 localhost 或手机在局域网中的地址。
服务器收到回调后,不能只根据 eSewa POST 的 payment proof 判断支付成功。还需要:
- 通过
ProductId查询本地订单。 - 使用 eSewa 官方交易验证接口,或按照当前文档要求验证签名。
- 核对订单编号、金额、商户身份和支付状态。
- 在数据库事务中以幂等方式将订单更新为已支付。
这里的 ProductId 更适合作为商户订单号,而不是固定的商品 SKU。同一个 Product A 被购买多次时,每笔订单都应使用不同的 ProductId。商品的固定编号可以单独保存为 sku 或 product_id。
Callback URL 应如何创建
Callback URL 就是一个普通的 HTTP POST API,可以使用 Node.js、Java、PHP、Python 等任意后端技术实现。
例如,生产环境可以提供:
POST https://api.example.com/payments/esewa/callback
Android 端传入完整地址:
ESewaPayment eSewaPayment = new ESewaPayment(
productPrice,
productName,
orderId,
"https://api.example.com/payments/esewa/callback"
);
其中,orderId 应由服务器创建,不能由 Android 客户端随意生成:
ORD-20260914-8F3A72C1
完整的支付流程如下:
Android 请求创建订单
↓
服务器生成唯一 orderId,并保存金额、商品和用户信息
↓
服务器将 orderId、金额等信息返回 Android
↓
Android 调用 eSewa SDK
↓
eSewa 向 Callback URL 发送 payment proof
↓
服务器向 eSewa 再次验证交易
↓
验证成功后将订单更新为 PAID
服务器需要部署在互联网可以访问的域名上,并建议只开放 HTTPS。以下地址通常不能用作正式回调地址:
http://localhost:3000/callback
http://127.0.0.1/callback
http://192.168.1.10/callback
开发阶段可以使用临时公网隧道,但要先确认 eSewa 测试环境允许使用该域名,也不要把临时地址用于生产支付。
Node.js/Express 实现示例
以下示例展示了回调接口的基本结构。回调字段名和 eSewa 验证接口的具体请求格式可能因 SDK、API 版本和商户配置而不同,请以实际接入版本的官方文档为准。
import express from "express";
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.post("/payments/esewa/callback", async (req, res) => {
try {
// 实际字段可能来自 JSON,也可能来自 form-urlencoded。
// 必须按照当前 eSewa 文档映射字段,不能直接假定字段名称。
const proof = req.body;
const orderId =
proof.productId ??
proof.product_id ??
proof.transaction_uuid;
if (!orderId) {
return res.status(400).json({ message: "Missing order identifier" });
}
const order = await findOrderByOrderId(orderId);
if (!order) {
return res.status(404).json({ message: "Order not found" });
}
// 重复回调直接返回成功,避免重复发货。
if (order.status === "PAID") {
return res.status(200).json({ message: "Already processed" });
}
const verification = await verifyPaymentWithEsewa({
order,
proof
});
if (!verification.valid) {
await recordFailedCallback({
orderId,
proof,
reason: verification.reason
});
return res.status(400).json({ message: "Payment verification failed" });
}
await markOrderPaidAtomically({
orderId: order.orderId,
transactionId: verification.transactionId,
paidAmount: verification.amount,
rawProof: proof
});
return res.status(200).json({ message: "OK" });
} catch (error) {
console.error("eSewa callback error", error);
return res.status(500).json({ message: "Internal server error" });
}
});
app.listen(3000);
verifyPaymentWithEsewa() 不能只是本地条件判断。它需要按照当前 eSewa 版本的要求完成服务端验证,例如:
- 调用官方 transaction/status verification API;
- 验证回调中的数字签名;
- 某些版本可能同时要求签名验证和状态查询。
不要自行推测验证 URL、签名字段顺序或加密算法。旧版 eSewa 接口与新版 ePay/API 的验证方式可能不同,应使用与当前 SDK 和商户环境对应的文档。
如何验证 payment proof
payment proof 只能视为查询这笔交易的通知,不能单独作为最终支付凭证。服务端至少要完成以下检查。
1. 根据订单号查询本地订单
订单必须在发起支付前创建并保存:
{
"orderId": "ORD-20260914-8F3A72C1",
"userId": "USER-1008",
"sku": "PRODUCT-A",
"expectedAmount": "500.00",
"currency": "NPR",
"status": "PENDING"
}
如果数据库中找不到回调携带的订单号,服务端不应自动创建一笔已支付订单。
2. 使用官方接口或签名核验真实性
服务端应向 eSewa 官方验证接口提交文档要求的信息,或者根据文档验证签名。Client Secret、Secret Key 等敏感信息只能保存在服务器中:
const ESEWA_SECRET_KEY = process.env.ESEWA_SECRET_KEY;
不要把 Secret Key 写入 Android APK。APK 可以被反编译,因此客户端中的密钥不能视为秘密。
3. 对比关键数据
验证结果需要同时满足以下条件:
验证状态成功
商户订单号与本地 orderId 相同
实际支付金额与 expectedAmount 相同
商户身份与当前商户相同
交易号尚未被其他订单使用
环境与当前配置一致
比较金额时,不要直接使用 JavaScript 浮点数:
// 不推荐
if (paidAmount === 0.1 + 0.2) {
// ...
}
可以把金额换算成最小货币单位,或者使用 Decimal 类型:
function toMinorUnits(amount) {
const normalized = String(amount).trim();
if (!/^\d+(\.\d{1,2})?$/.test(normalized)) {
throw new Error("Invalid amount");
}
const [whole, fraction = ""] = normalized.split(".");
return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, "0"));
}
if (toMinorUnits(verifiedAmount) !== toMinorUnits(order.expectedAmount)) {
throw new Error("Amount mismatch");
}
是否按两位小数转换,需要根据 eSewa 当前对 NPR 金额格式的规定确认。
4. 幂等更新订单
eSewa 可能因网络超时重复发送同一个回调,Android 端也可能重复提交结果,所以订单处理必须支持重复调用。
可以在数据库中为交易号添加唯一约束:
CREATE UNIQUE INDEX uq_payment_transaction_id
ON payments(transaction_id);
更新订单时增加状态条件:
UPDATE orders
SET status = 'PAID',
paid_at = CURRENT_TIMESTAMP
WHERE order_id = ?
AND status = 'PENDING';
只有第一次更新成功时,才能执行发货、充值或开通服务。后续回调重试不能触发重复发货。
Android 返回结果不能代替服务器回调
startActivityForResult() 返回的结果主要用于更新 Android 界面,比如显示“正在确认支付”,不能作为服务器发货的唯一依据。
用户可能在支付完成后关闭应用,客户端返回结果也可能被伪造。最终的业务状态应由服务器确认:
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
super.onActivityResult(requestCode, resultCode, data);
if (requestCode == REQUEST_CODE_PAYMENT) {
// 不要在这里直接发货或永久解锁服务。
// 应请求自己的服务器查询订单最终状态。
refreshOrderStatusFromServer();
}
}
服务端还应准备主动补偿机制。如果 eSewa 已经扣款,但回调没有送达,服务器可以主动查询仍处于 PENDING 状态的订单。
ProductId 应如何设计
在支付接口中,ProductId 通常表示本次交易对应的唯一商户订单号,不是商品目录中的固定商品编号。
例如,Product A 的固定 SKU 可以是:
PRODUCT-A
购买三次时,应分别创建三个订单号:
ORD-20260914-000001
ORD-20260914-000002
ORD-20260914-000003
数据库记录可以是:
| order_id | sku | user_id | status |
|---|---|---|---|
ORD-20260914-000001 | PRODUCT-A | USER-1 | PAID |
ORD-20260914-000002 | PRODUCT-A | USER-2 | PAID |
ORD-20260914-000003 | PRODUCT-A | USER-1 | PENDING |
这样才能区分不同的用户、购买时间和支付状态。
订单号可以使用 UUID 或安全随机值:
import crypto from "crypto";
function createOrderId() {
return `ORD-${crypto.randomUUID()}`;
}
也可以直接使用数据库生成的订单编号。无论选择哪种方案,都要满足以下要求:
- 每笔订单的编号唯一;
- 编号不容易被猜测或篡改;
- 长度和字符范围符合 eSewa 当前接口限制;
- 先在服务器中保存订单号,再传给 Android;
- 是否在支付重试时复用订单号,应由业务规则决定。
如果用户只是重新打开 eSewa,继续支付同一笔未完成订单,通常可以沿用原订单号。如果原订单已经取消或失败,重新创建的订单最好使用新编号,便于审计。不要让多笔独立购买长期共用 PRODUCT-A 作为 ProductId,否则回调很难对应到具体订单。
安全与上线注意事项
- Callback URL 必须能被 eSewa 服务器访问,不能依赖用户登录 Cookie。
- 回调没有登录态,不代表可以直接信任。请求的真实性应通过签名或官方验证接口确认。
- 不要相信 Android 传来的商品价格。服务器应根据 SKU 计算价格。
- 日志中不要记录 Secret Key、完整签名或非必要的用户隐私数据。
- DEVELOPMENT 和 LIVE 环境应分别配置地址、凭据和验证接口。
- 保存对账所需的原始回调和验证结果,同时限制这些数据的访问权限。
- 只有服务器确认支付成功后,才能发货、充值或开通权限。
- 回调接口应尽快完成处理。耗时的发货任务可以在订单写入数据库后交给队列执行。
- 按照 eSewa 的要求返回 HTTP 状态和响应格式,具体响应体以当前 SDK/API 文档为准。
备注:内容仅供参考。
