PAYATHON 2026

如何使用 Node.js 和 Paytrail API 创建在线支付?

支付老李

结论

支付请求应该由 Node.js 后端直接发送给 Paytrail。不要先把 HMAC 返回给前端,再让浏览器调用 Paytrail。

完整流程如下:

  1. 前端请求自己的后端创建支付。
  2. 后端生成订单数据、nonce 和当前时间戳。
  3. 后端使用 Paytrail 密钥,对请求头和”实际发送的 JSON 字符串”计算 HMAC。
  4. 后端把 HMAC 写入 checkout-signature 请求头,然后调用 POST https://services.paytrail.com/payments。
  5. 后端验证 Paytrail 的响应签名,再把支付地址返回给前端。
  6. 前端跳转到 Paytrail 返回的支付页面。

checkout-nonce 不是 HMAC,也不应该填写 "server hash"。用于存放签名的请求头是 checkout-signature。

为什么不能由前端调用 Paytrail

生成 HMAC 需要使用商户密钥。如果浏览器可以自行完成签名,它就必须直接或间接拿到密钥。这样一来,任何访问网站的人都有可能伪造支付请求。

订单金额、商品、税率和订单编号也应该由后端根据数据库中的商品与订单计算,不能直接相信前端提交的金额。

浏览器只需要告诉后端”为这个订单创建支付”。签名和 Paytrail API 调用都由后端处理。

HMAC 的计算方式

所有名称以 checkout- 开头的请求头都要参与签名,但此时尚未生成的 checkout-signature 除外。先按请求头名称排序,再转换成下面的格式:

checkout-account:375917
checkout-algorithm:sha256
checkout-method:POST
checkout-nonce:...
checkout-timestamp:...
{"stamp":"...","reference":"...",...}

请求体必须紧跟在请求头字符串后面。这里最常见的问题是,计算签名时使用的 JSON 与实际发送的 JSON 不完全相同。

先执行一次:

const bodyText = JSON.stringify(payment);

然后使用同一个 bodyText 计算 HMAC,并将它原样发送给 Paytrail。

Node.js 后端示例

下面的示例使用 Node.js 18+ 内置的 fetch 和 Express。生产环境中的账户编号和密钥应该保存在环境变量里。

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

const app = express();
app.use(express.json());

const PAYTRAIL_ACCOUNT = process.env.PAYTRAIL_ACCOUNT || '375917';
const PAYTRAIL_SECRET = process.env.PAYTRAIL_SECRET || 'SAIPPUAKAUPPIAS';
const PAYTRAIL_URL = 'https://services.paytrail.com/payments';

function calculateHmac(secret, headers, bodyText = '') {
  const checkoutHeaders = Object.entries(headers)
    .filter(([name]) => name.toLowerCase().startsWith('checkout-'))
    .filter(([name]) => name.toLowerCase() !== 'checkout-signature')
    .sort(([a], [b]) => a.localeCompare(b))
    .map(([name, value]) => `${name.toLowerCase()}:${value}`);

  const payload = [...checkoutHeaders, bodyText].join('\n');

  return crypto
    .createHmac('sha256', secret)
    .update(payload, 'utf8')
    .digest('hex');
}

function signaturesEqual(received, expected) {
  if (!received || received.length !== expected.length) {
    return false;
  }

  return crypto.timingSafeEqual(
    Buffer.from(received, 'utf8'),
    Buffer.from(expected, 'utf8')
  );
}

app.post('/api/payments', async (req, res) => {
  try {
    /*
     * 实际项目中,前端应只发送 orderId。
     * 后端再从数据库读取商品、金额和客户信息。
     */
    const orderId = req.body.orderId || crypto.randomUUID();

    const payment = {
      stamp: crypto.randomUUID(),
      reference: String(orderId),
      amount: 1525,
      currency: 'EUR',
      language: 'FI',
      items: [
        {
          unitPrice: 1525,
          units: 1,
          vatPercentage: 24,
          productCode: '#1234',
          deliveryDate: '2026-09-01'
        }
      ],
      customer: {
        email: 'test.customer@example.com'
      },
      redirectUrls: {
        success: 'https://example.com/payment/success',
        cancel: 'https://example.com/payment/cancel'
      },
      callbackUrls: {
        success: 'https://example.com/api/paytrail/callback/success',
        cancel: 'https://example.com/api/paytrail/callback/cancel'
      }
    };

    const bodyText = JSON.stringify(payment);

    const checkoutHeaders = {
      'checkout-account': PAYTRAIL_ACCOUNT,
      'checkout-algorithm': 'sha256',
      'checkout-method': 'POST',
      'checkout-nonce': crypto.randomUUID(),
      'checkout-timestamp': new Date().toISOString()
    };

    const signature = calculateHmac(
      PAYTRAIL_SECRET,
      checkoutHeaders,
      bodyText
    );

    const paytrailResponse = await fetch(PAYTRAIL_URL, {
      method: 'POST',
      headers: {
        ...checkoutHeaders,
        'checkout-signature': signature,
        'content-type': 'application/json; charset=utf-8'
      },
      body: bodyText
    });

    /*
     * 必须先保留原始响应文本。若先解析再 JSON.stringify,
     * 字符串格式可能变化,导致响应签名验证失败。
     */
    const responseText = await paytrailResponse.text();

    const responseHeaders = {};
    paytrailResponse.headers.forEach((value, name) => {
      responseHeaders[name.toLowerCase()] = value;
    });

    const receivedSignature =
      responseHeaders['checkout-signature'];

    const expectedSignature = calculateHmac(
      PAYTRAIL_SECRET,
      responseHeaders,
      responseText
    );

    if (
      receivedSignature &&
      !signaturesEqual(receivedSignature, expectedSignature)
    ) {
      return res.status(502).json({
        error: 'Invalid signature in Paytrail response'
      });
    }

    let responseBody;

    try {
      responseBody = JSON.parse(responseText);
    } catch {
      responseBody = { raw: responseText };
    }

    if (!paytrailResponse.ok) {
      return res.status(paytrailResponse.status).json({
        error: 'Paytrail rejected the payment request',
        details: responseBody
      });
    }

    /*
     * 保存 transactionId、stamp、reference 和订单之间的关联,
     * 供回调验证及后续状态查询使用。
     */
    res.json({
      transactionId: responseBody.transactionId,
      href: responseBody.href,
      providers: responseBody.providers
    });
  } catch (error) {
    console.error(error);

    res.status(500).json({
      error: 'Unable to create payment'
    });
  }
});

app.listen(3000, () => {
  console.log('Server listening on http://localhost:3000');
});

安装并启动:

npm install express
PAYTRAIL_ACCOUNT=375917 \
PAYTRAIL_SECRET=SAIPPUAKAUPPIAS \
node server.js

Vue 前端示例

前端只调用自己的后端,不需要添加任何 checkout-* 请求头:

<template>
  <button :disabled="loading" @click="createPayment">
    {{ loading ? 'Creating payment...' : 'Create payment' }}
  </button>
</template>

<script setup>
import { ref } from 'vue';
import axios from 'axios';

const loading = ref(false);

async function createPayment() {
  loading.value = true;

  try {
    const response = await axios.post('/api/payments', {
      orderId: '12345'
    });

    if (!response.data.href) {
      throw new Error('Paytrail did not return a payment URL');
    }

    window.location.assign(response.data.href);
  } catch (error) {
    console.error(
      'Payment creation failed:',
      error.response?.data || error
    );
  } finally {
    loading.value = false;
  }
}
</script>

如果前后端使用不同的域名或端口,需要在自己的 Node.js 服务上配置 CORS,也可以通过前端开发服务器代理 /api。这和 Paytrail 的 HMAC 验证没有关系。

回调和支付结果

用户被重定向到 redirectUrls.success,并不表示支付已经成功。用户可能修改 URL、重复访问页面,也可能在重定向完成前关闭浏览器。

应该按以下方式处理支付结果:

  • 验证 Paytrail 返回参数中的签名。
  • 确认交易编号属于当前订单。
  • 核对金额、订单引用和支付状态。
  • 通过 callbackUrls 接收服务端通知。
  • 必要时,由后端调用交易状态查询接口。
  • 只有后端验证成功后,才能把订单标记为已支付。
  • 回调处理需要具备幂等性,重复通知不能造成重复发货。

回调签名也必须由后端验证。不能仅凭前端访问了 success 页面,就直接更新订单状态。

原代码中的主要问题

原实现混淆了几个不同的概念:

  • checkout-nonce 应该是每次请求唯一的随机值,而不是 HMAC。
  • HMAC 应该写入 checkout-signature 请求头。
  • 前端请求体使用了另一套字段,例如 orderNumber、locale 和 returnUrl。它与后端签名时使用的请求体完全不同,所以签名不会匹配。
  • 时间戳不能长期写死。每次发送请求前,都应该通过 new Date().toISOString() 生成。
  • 请求地址应该使用支付创建端点 /payments,不能使用服务根地址。
  • Paytrail 金额通常以货币的最小单位表示。1525 表示 15.25 EUR,不是 1525 EUR。具体字段单位应以当前 API 文档为准。
  • 测试凭据可以用于开发测试,生产凭据则必须保存在后端环境变量或密钥管理系统中。
  • stamp、reference 和 checkout-nonce 的用途不同。stamp 用来唯一标识支付,reference 是商户订单引用,nonce 用于防止同一签名请求被重放。

备注:内容仅供参考。