如何使用 Node.js 和 Paytrail API 创建在线支付?
结论
支付请求应该由 Node.js 后端直接发送给 Paytrail。不要先把 HMAC 返回给前端,再让浏览器调用 Paytrail。
完整流程如下:
- 前端请求自己的后端创建支付。
- 后端生成订单数据、
nonce和当前时间戳。 - 后端使用 Paytrail 密钥,对请求头和”实际发送的 JSON 字符串”计算 HMAC。
- 后端把 HMAC 写入
checkout-signature请求头,然后调用POST https://services.paytrail.com/payments。 - 后端验证 Paytrail 的响应签名,再把支付地址返回给前端。
- 前端跳转到 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用于防止同一签名请求被重放。
备注:内容仅供参考。
