Phát triển
Chữ ký webhook
Xác minh chữ ký HMAC-SHA256 để chắc chắn webhook đến từ PayFS.
Kiểm tra API key đã đủ để biết ai gọi. Kiểm tra chữ ký cho biết thêm rằng nội dung không bị sửa trên đường truyền, và chống tấn công phát lại.
Nên làm với mọi endpoint liên quan tới tiền.
Thuật toán
PayFS ký theo ba bước:
1. sorted = sắp xếp khóa JSON theo alphabet, đệ quy
2. data = timestamp + "." + JSON.stringify(sorted)
3. sig = HMAC_SHA256(data, webhook_secret) dạng hexBa điểm hay làm sai:
- Có dấu chấm giữa timestamp và payload. Thiếu nó thì chữ ký không bao giờ khớp.
- Khóa JSON phải được sắp xếp đúng như PayFS đã làm.
- Chữ ký là hex thô 64 ký tự, không có tiền tố. Đừng tìm
sha256=, PayFS không gửi.
Chữ ký nằm ở header X-PayFS-Signature, timestamp ở X-PayFS-Timestamp.
Chữ ký hết hạn sau 5 phút.
Node.js
const crypto = require('crypto');
function sortObjectKeys(obj) {
if (typeof obj !== 'object' || obj === null) return obj;
if (Array.isArray(obj)) return obj.map(sortObjectKeys);
return Object.keys(obj).sort().reduce((acc, k) => {
acc[k] = sortObjectKeys(obj[k]);
return acc;
}, {});
}
function verifyWebhookSignature(payload, signature, timestamp, secret) {
// 1. Chặn tấn công phát lại
if (Math.floor(Date.now() / 1000) - timestamp > 300) {
return { valid: false, error: 'Chữ ký đã hết hạn' };
}
// 2. Dựng lại chuỗi được ký
const sorted = JSON.stringify(sortObjectKeys(JSON.parse(payload)));
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${sorted}`, 'utf8')
.digest('hex');
// 3. So sánh theo thời gian hằng số.
// Kiểm tra độ dài trước, vì timingSafeEqual ném lỗi khi hai buffer lệch độ dài.
if (signature.length !== expected.length) {
return { valid: false, error: 'Chữ ký không hợp lệ' };
}
const ok = crypto.timingSafeEqual(
Buffer.from(signature, 'hex'),
Buffer.from(expected, 'hex')
);
return ok ? { valid: true } : { valid: false, error: 'Chữ ký không hợp lệ' };
}Dùng trong endpoint:
app.post('/payfs-webhook', (req, res) => {
const apiKey = req.headers['x-client-api-key'];
if (apiKey !== process.env.PAYFS_WEBHOOK_API_KEY) {
return res.status(401).json({ error: 'Unauthorized' });
}
const result = verifyWebhookSignature(
JSON.stringify(req.body),
req.headers['x-payfs-signature'],
parseInt(req.headers['x-payfs-timestamp'], 10),
process.env.PAYFS_WEBHOOK_SECRET
);
if (!result.valid) {
return res.status(401).json({ error: result.error });
}
handleTransaction(req.body);
res.status(200).json({ status: 'ok' });
});PHP
<?php
function sortObjectKeys($obj) {
if (!is_array($obj)) return $obj;
if (array_keys($obj) === range(0, count($obj) - 1)) {
return array_map('sortObjectKeys', $obj);
}
ksort($obj);
foreach ($obj as $k => $v) $obj[$k] = sortObjectKeys($v);
return $obj;
}
function verifyWebhookSignature($payload, $signature, $timestamp, $secret) {
if (time() - $timestamp > 300) {
return false;
}
$sorted = json_encode(sortObjectKeys(json_decode($payload, true)), JSON_UNESCAPED_SLASHES);
$expected = hash_hmac('sha256', $timestamp . '.' . $sorted, $secret);
return hash_equals($expected, $signature);
}Kiểm thử
Ví dụ dưới đây tái lập được. Chạy thử để chắc chắn code của bạn đúng.
const crypto = require('crypto');
const secret = 'whsec_example_secret_do_not_use_in_production';
const timestamp = 1758173916;
const payload = '{"account_id":"1418079746853494784","amount":14000,"bank":"MB","bank_account_number":"0933723830","content":"NGUYEN VAN A chuyen tien Ma giao dich Trace773231","transaction_date":"2025-09-15T15:02:00.000Z","transaction_id":"1418108930751619072","transfer_type":"credit"}';
const sig = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${payload}`, 'utf8')
.digest('hex');
console.log(sig);
// 86f02cefae56d51f72a00e04bf9d5a96b40cfe6902d54e495234c447ec706c5eKhông ra đúng chuỗi trên nghĩa là bạn đã sai ở một trong ba điểm nêu đầu trang.
Xử lý sự cố
Chữ ký không hợp lệ
- Thiếu dấu chấm giữa timestamp và payload
- Khóa JSON chưa được sắp xếp
- Code đang cắt tiền tố
sha256=mà PayFS không gửi - Sai webhook secret
- Payload bị sửa trước khi xác minh, hãy dùng body thô
Chữ ký đã hết hạn
Đồng hồ máy chủ lệch, hoặc endpoint xử lý quá 5 phút. Đồng bộ giờ bằng NTP, và trả 200 trước rồi xử lý nghiệp vụ sau.