PAYATHON 2026

使用 REST API 集成 Payumoney Payment Gateway

支付小周

结论

https://test.payu.in/_payment 是托管收银台入口,正常返回的是 HTML,不是 JSON。如果 Node.js 在后台通过 request.post() 调用该地址,只会把收银台 HTML 下载到服务器,用户的浏览器不会跳转。

正确的支付流程如下:

  1. Angular 请求 Node.js 创建支付订单。
  2. Node.js 使用商户 key 和 salt 生成 SHA-512 hash。
  3. Angular 通过 HTML <form method="POST"> 将支付字段直接提交到 https://test.payu.in/_payment。
  4. 支付完成后,PayU 通过 POST 请求将结果发送到 surl 或 furl。
  5. Node.js 验证回调哈希和交易状态,然后跳转到 Angular 的结果页面。

当前请求出现 Checksum Failed,最直接的原因是 hash 为空。即使已经生成哈希,只要签名所用的字段值、字段顺序、空字段数量或字符串格式与实际提交的数据不一致,校验仍会失败。

为什么后台 POST 不会跳转浏览器

下面的请求发生在 Node.js 服务器内部:

request.post({
  url: 'https://test.payu.in/_payment',
  form: paymentData
}, callback);

实际网络路径是:

Node.js server -> PayU -> HTML response -> Node.js server

用户浏览器没有参与这次请求,所以 PayU 返回的 HTML 只会进入回调函数。即使 Express 随后把 HTML 返回给 Angular 的 HttpClient,它也只是 XHR/fetch 的响应内容,不会触发页面跳转。

PayU 的这种接入方式要求浏览器通过表单 POST 进入托管收银台,并不是常见的 REST JSON API。

Checksum Failed 的常见原因

1. hash 没有生成

示例中提交的是空哈希:

hash: ''

PayU 无法验证这样的请求,因此会直接拒绝。

经典 PayU Hosted Checkout 接口通常使用以下字符串生成 SHA-512 签名:

key|txnid|amount|productinfo|firstname|email|udf1|udf2|udf3|udf4|udf5||||||SALT

计算方式为:

SHA512(signingString)

签名规则可能因接入年代、账户类型或 PayU 产品而异。具体应以商户后台当前提供的接入文档为准,尤其要确认 udf 字段数量以及是否需要 service_provider。

2. 签名值与提交值不完全一致

以下差异都会导致校验失败:

  • 签名时使用 8200.00,提交时发送 8200
  • 生成签名后又修改了 productinfo
  • firstname 或 email 两侧有空格
  • 签名时 udf1 是 undefined,提交时却是 ''
  • JSON 的属性顺序、空格或转义方式发生变化
  • txnid 被当作数字处理
  • 拼接签名时少写或多写了一个 |
  • 测试环境使用了生产环境的 key 或 salt

签名并不是直接对 JavaScript 对象进行计算,而是对按照规定顺序拼接出的精确字符串进行计算。

3. txnid 应当使用字符串

原代码中的写法:

txnid: 578cb861e9c38ecc185ec8e7

不是有效的 JavaScript 字符串字面量,应改为:

txnid: '578cb861e9c38ecc185ec8e7'

交易号不能参与数学运算,也不应转换为数值。使用 MongoDB ObjectId 时,需要先明确转换为字符串。

4. productinfo 内容过于复杂

productinfo 并非不能使用长 JSON,但复杂内容很容易因序列化差异造成签名与提交数据不一致。更稳妥的方式是只传递稳定、简短的描述或订单号,例如:

productinfo: 'Order 578cb861e9c38ecc185ec8e7'

完整购物车应保存在 MongoDB 中,支付完成后通过 txnid 查询,不要依赖支付网关回传整份订单内容。

5. 错误使用 Authorization 请求头

经典 _payment 表单接口主要通过表单中的 key 和 hash 进行校验,不依赖任意设置的 Authorization 值:

headers: {
  Authorization: '...'
}

除非当前 PayU 文档明确要求使用该请求头,否则不要自行添加。它不能代替表单中的 hash。

如果问题中展示的值是真实凭据,应立即在商户后台轮换或撤销。商户 salt 和其他私密凭据不能放入 Angular、Git 仓库或浏览器响应。

Node.js 生成支付参数

下面的示例采用 PayU 的经典签名方式。测试地址、字段要求和签名公式仍需根据当前商户文档核对。

const express = require('express');
const crypto = require('crypto');

const app = express();

app.use(express.json());
app.use(express.urlencoded({ extended: false }));

const PAYU_URL = 'https://test.payu.in/_payment';
const PAYU_KEY = process.env.PAYU_KEY;
const PAYU_SALT = process.env.PAYU_SALT;

function sha512(value) {
  return crypto
    .createHash('sha512')
    .update(value, 'utf8')
    .digest('hex');
}

function createPaymentHash(fields) {
  const signingString = [
    fields.key,
    fields.txnid,
    fields.amount,
    fields.productinfo,
    fields.firstname,
    fields.email,
    fields.udf1,
    fields.udf2,
    fields.udf3,
    fields.udf4,
    fields.udf5,
    '',
    '',
    '',
    '',
    '',
    PAYU_SALT
  ].join('|');

  return sha512(signingString);
}

app.post('/api/payments/payu', async (req, res) => {
  try {
    const order = {
      id: '578cb861e9c38ecc185ec8e7',
      amount: '8200.00'
    };

    /*
     * 实际项目中应当:
     * 1. 根据当前登录用户和商品数据在服务器重新计算金额;
     * 2. 在 MongoDB 中创建 pending 状态的订单;
     * 3. 保证 txnid 唯一。
     */
    const fields = {
      key: PAYU_KEY,
      txnid: order.id,
      amount: order.amount,
      productinfo: `Order ${order.id}`,
      firstname: 'Rayees',
      lastname: 'Mir',
      email: 'rayees@mir.com',
      phone: '9797187225',
      surl: 'https://api.example.com/api/payments/payu/callback',
      furl: 'https://api.example.com/api/payments/payu/callback',
      udf1: '',
      udf2: '',
      udf3: '',
      udf4: '',
      udf5: ''
    };

    fields.hash = createPaymentHash(fields);

    res.json({
      action: PAYU_URL,
      method: 'POST',
      fields
    });
  } catch (error) {
    console.error(error);
    res.status(500).json({
      message: 'Unable to create PayU payment'
    });
  }
});

建议先把 amount 规范为固定格式,例如:

const amount = Number(order.total).toFixed(2);

金额必须由服务器根据可信的订单数据计算,不能直接使用 Angular 提交的金额。

某些旧版 PayUMoney 接入还要求提供:

service_provider: 'payu_paisa'

是否需要这个字段取决于账户使用的接口版本。没有文档依据时不要随意添加,因为不同版本对请求字段和签名字段的规定可能不同。

Angular 提交表单并跳转

Angular 从后端取得签名后的字段,再动态创建表单。提交表单后,浏览器会导航到 PayU 收银台:

interface PayuCheckoutResponse {
  action: string;
  method: 'POST';
  fields: Record<string, string>;
}

startPayment(): void {
  this.http
    .post<PayuCheckoutResponse>('/api/payments/payu', {})
    .subscribe({
      next: payment => {
        const form = document.createElement('form');

        form.method = payment.method;
        form.action = payment.action;
        form.style.display = 'none';

        for (const [name, value] of Object.entries(payment.fields)) {
          const input = document.createElement('input');

          input.type = 'hidden';
          input.name = name;
          input.value = value ?? '';

          form.appendChild(input);
        }

        document.body.appendChild(form);
        form.submit();
      },
      error: error => {
        console.error('Unable to start payment', error);
      }
    });
}

这里不能使用普通的 Angular HttpClient.post() 直接请求 PayU。跨站 AJAX 可能受到 CORS 限制,而且不会触发预期的整页导航。

另一种做法是由 Express 生成自动提交的 HTML 表单,再让浏览器直接访问对应的 Express 路由。对于 Angular SPA,通常由后端返回支付字段,再由前端创建表单,这样更容易处理加载状态和错误提示。

正确处理 surl 和 furl

不要把 surl、furl 设置为 Google、Facebook 或 Angular 的纯前端路由。它们应该先指向一个可公开访问的后端 HTTPS 地址,例如:

https://api.example.com/api/payments/payu/callback

PayU 通常会通过表单 POST 将支付结果发送到该地址。Express 接收并验证结果后,再使用 303 跳转到 Angular 页面:

app.post('/api/payments/payu/callback', async (req, res) => {
  try {
    const payment = req.body;

    const validHash = verifyPayuResponseHash(payment);

    if (!validHash) {
      return res.status(400).send('Invalid payment response');
    }

    /*
     * 根据 txnid 查找 MongoDB 订单。
     * 最好再调用 PayU 的交易查询/验证接口确认最终状态。
     * 更新订单时应做幂等处理,防止重复回调和重复履约。
     */

    if (payment.status === 'success') {
      return res.redirect(
        303,
        `https://app.example.com/payment/result?txnid=${encodeURIComponent(payment.txnid)}`
      );
    }

    return res.redirect(
      303,
      `https://app.example.com/payment/failed?txnid=${encodeURIComponent(payment.txnid)}`
    );
  } catch (error) {
    console.error(error);
    res.status(500).send('Payment callback processing failed');
  }
});

经典响应哈希通常按照反向顺序验证:

function verifyPayuResponseHash(payment) {
  const reverseHashString = [
    PAYU_SALT,
    payment.status ?? '',
    '',
    '',
    '',
    '',
    '',
    '',
    '',
    '',
    '',
    '',
    payment.email ?? '',
    payment.firstname ?? '',
    payment.productinfo ?? '',
    payment.amount ?? '',
    payment.txnid ?? '',
    payment.key ?? ''
  ].join('|');

  const expectedHash = sha512(reverseHashString);

  return expectedHash.toLowerCase() ===
    String(payment.hash ?? '').toLowerCase();
}

这套响应公式只适用于对应的经典接口规则。如果响应中包含 additionalCharges、additional_charges,或者账户使用了其他版本的哈希协议,就需要按照该版本的规则调整拼接方式。

安全和上线注意事项

  • salt 只能保存在 Node.js 服务端,例如环境变量或密钥管理系统中。
  • 不要信任前端提供的金额、商品名称、用户邮箱或订单状态。
  • 调用 PayU 前应创建唯一订单,并记录预期金额、用户和 txnid。
  • 处理回调时,需要同时核对 key、txnid、amount、订单归属和哈希。
  • 浏览器显示“支付成功”不代表资金已经确认。条件允许时,还应调用 PayU 的服务端交易验证接口确认。
  • 回调可能重复发送,因此订单更新和发货逻辑必须支持幂等处理。
  • 测试环境和生产环境的 URL、key、salt 必须配套使用。
  • 不要让支付成功完全依赖前端跳转,因为用户可能在付款后直接关闭页面。
  • 日志中不要记录 salt、完整的签名原文、银行卡信息或其他敏感数据。

整个流程可以归纳为三步:服务器生成签名,用户浏览器提交支付表单,服务器验证支付回调。如果把 _payment 当作普通 REST JSON 接口,由 Node.js 在后台发起请求,用户就无法正常跳转到托管收银台。

备注:内容仅供参考。